Author SHA1 Message Date
Commander1024 797d2681ac Migrate to IDF 5.5.3 candidate
Pin PlatformIO packages and toolchains, rebase protected SDK
overrides, and add WebSocket receive regression coverage. Document
isolated candidate validation, archive provenance, and remaining gates.
2026-09-18 14:23:13 +02:00
Commander1024 cdc4d4a8df Add Phase 9 validation and advisory review
Record the finite dependency search, Wi-Fi maintenance blocker, and
pinned
icon provenance. Add bounded host orchestration and fixture coverage,
and
update release documentation with current evidence.
2026-09-16 16:26:46 +02:00
Commander1024 51f835c22f Harden SSH parsing and add notice tooling
- Enforce exact service and channel names with bounded failure parsing
- Add hash-pinned offline notice assembly and regression coverage
- Record advisory dispositions, provenance, integration evidence, and
  remaining gates
2026-09-16 15:06:38 +02:00
Commander1024 bea33e1c95 Add restricted wolfSSH ordering fix
Apply hash-pinned generated edits for CVE-2025-14942 while keeping
wolfSSH 1.4.20 managed sources unchanged. Add the ABI header overlay,
provenance records, and real state-machine interoperability contracts.
2026-09-16 14:04:34 +02:00
Commander1024 4d3bb490c9 Harden wolfSSL and wolfSSH validation
Enable validated ECC imports and X25519 all-zero rejection through
PUBLIC build policy. Tighten wolfSSH parser bounds, overflow handling,
and signature framing with guard-page and crypto vector contracts.
2026-09-15 23:54:39 +02:00
Commander1024 c010e1a1d5 Apply Phase 9D security mitigations
- Add fail-closed wolfSSL small-math policy and vectors
- Backport DHCP, EMS, and X.509 allocation fixes
- Extend source override validation and operational documentation
2026-09-15 23:06:23 +02:00
Commander1024 cdc9c7335a Add Phase 9C security hardening
Generate exact-hash SDK source overrides without modifying dependencies.
Harden
SSH allocation and algorithm policy, tighten web authentication cleanup,
and add
focused host contract tests and documentation.
2026-09-15 22:12:57 +02:00
Commander1024 751dfb9ddb Harden SSH Admission And Credential Input 2026-09-15 20:49:04 +02:00
Commander1024 436c27adb1 Enforce crash-safe build policy
Add compile-time checks for silent reboot, disabled core dumps, and
disabled debugger-aware panic handling. Include regression coverage,
hardening guidance, and update Phase 8/9 project status.
2026-09-15 20:11:40 +02:00
Commander1024 f40c09c11a Refine web quick panels and Wi-Fi controls 2026-09-14 00:05:27 +02:00
Commander1024 4a53a21f31 Move console payloads to lazy PSRAM storage 2026-09-13 23:06:42 +02:00
Commander1024 91267b371e Consolidate Phase 8 documentation
Mark web administration complete, centralize current contracts and
acceptance evidence, and remove superseded slice records. Update
roadmap,
architecture notes, and test references without changing firmware
sources.
2026-09-13 22:27:10 +02:00
Commander1024 1608641d50 Close scope for remaining Phase 8D.19 work 2026-09-13 20:25:01 +02:00
Commander1024 8df1d2218b Add SSH host identity rotation controls 2026-09-13 19:58:05 +02:00
Commander1024 aa4bbc2c8c Add HTTPS identity rotation support 2026-09-13 18:21:37 +02:00
Commander1024 36e80811e8 Implement HTTPS lifecycle and reboot controls 2026-09-13 17:24:00 +02:00
Commander1024 737bd29f9e Add Typed SSH Service Controls
Provide admin-only SSH status plus generation-safe start, stop, and
single-session disconnect operations through the bounded dispatcher.
Include
Settings UI coverage, lifecycle safeguards, and host-side regression
tests.
2026-09-13 16:07:04 +02:00
Commander1024 7ccc8799e9 Add Broker client and writer quick dialogs 2026-09-13 14:40:51 +02:00
Commander1024 9f6ebf2053 Add Serial and Wi-Fi quick settings
Reuse the existing settings view and typed controllers for accessible
quick editing while keeping network credentials out of quick mode.
Expand browser and layout coverage for focus, dismissal, bounds, and
expiry.
2026-09-13 14:22:31 +02:00
Commander1024 add399908a Remove stray blank line in Phase 8D plan 2026-09-13 14:01:49 +02:00
Commander1024 fa12440606 Add broker management and writer transfer UI 2026-09-13 13:59:28 +02:00
Commander1024 29a4953df0 Remove network diagnostics from phase 8D plan 2026-09-09 10:32:55 +02:00
Commander1024 d9ec3c08de Add Typed Display Settings Administration
Implements admin-only Display settings with generation-checked
Apply, Save, Load, Defaults, and Reset operations across the web UI,
CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling,
session-isolated operation results, browser lifecycle support, and
comprehensive host tests and documentation.
2026-09-09 10:15:23 +02:00
Commander1024 60d9c54bb4 Send binary WebSocket frames in one write 2026-09-08 23:52:00 +02:00
Commander1024 042499e4d6 Add broker and web throughput diagnostics 2026-09-08 22:40:58 +02:00
Commander1024 36d41be422 Document 8D.12/8D.13 Functional Sign-Off 2026-09-08 21:40:18 +02:00
Commander1024 4a4d615c59 Unify Settings Layouts and Add Coverage 2026-09-08 21:22:52 +02:00
Commander1024 989821b7c4 Add Typed Admin Network Settings 2026-09-08 20:57:27 +02:00
243 changed files with 29361 additions and 2643 deletions
+6
View File
@@ -8,8 +8,14 @@ add_compile_definitions(
NO_WOLFSSL_ESP32_CRYPT_AES
NO_WOLFSSL_ESP32_CRYPT_HASH
WOLFSSL_ED25519_STREAMING_VERIFY
# CVE-2025-12888: upstream PR9275 small math policy for enabled algorithms.
# These affect public key layouts; library and consumers must agree.
CURVE25519_SMALL
ED25519_SMALL
)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
set(PROJECT_VER "0.1.0")
project(esp32_serial_swiss_army_knife)
include(cmake/security_overrides.cmake)
include(cmake/wolf_crypto_policy.cmake)
+39 -9
View File
@@ -14,13 +14,35 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
## Development status
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D.3 browser login/logout is implemented, host-tested and build-verified; [M1 is validated by user sign-off](docs/phase8d3_implementation.md) after both-role login, mixed-client operation and post-soak evidence. Numeric memory reserve gates remain open. Browser admin-shell mode, typed settings, and contextual administration remain planned, including preserving serial writer ownership across future terminal-mode changes. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance evidence](docs/web_administration_acceptance.md). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. **Phase 9 security hardening is in progress**: 9A crash/debug policy, 9B SSH admission/credential handling and 9C library cleanup/protocol policy have passed host/build checks. **9D maintenance and lifecycle is in progress:** an operator runbook, finite SSH parser/caller and IDF applicability reviews, and offline notice assembly tooling are available. Scoped corrections have parent host/build evidence; the [finite dependency search](docs/dependency_advisory_coverage.md) is complete (all 22 IDF advisory entries across three pages plus named managed/browser channels), but fresh findings, explicit coverage gaps, release delivery/source/legal questions and whole-phase target gates remain open. **The fix-bearing vendor Wi-Fi bundle is integrated in the validated IDF 5.5.3 candidate, not radio-hardware closure**: the [update plan](docs/wifi_security_update_plan.md) retains target gates and unchanged PMF/WPA3; **Phase 9 is not complete or production-ready**. The user will validate Phase 9 as a whole; no 9D device validation or license clearance is claimed. See [security hardening](docs/security_hardening.md) for scope, operational profiles, and validation gates.
Latest supplied parent integration: **fresh isolated IDF 5.5.3 application build PASS**, **95,552 B linked RAM / 1,749,493 B flash** (**+1,212 B RAM / 19,408 B flash** versus historical 94,340 / 1,768,901 B). Explicit-candidate Phase 9 validation with interop and web performance **PASS 24/24**. Default root `pio run` timed out at **200 seconds during installation, before compilation**—not a normal root build PASS. See [candidate integration](docs/idf_candidate_integration.md) and [exact command/snapshot evidence](docs/phase9_validation.md#current-candidate-execution--2026-09-18). Linked size and host passes are not target headroom or release acceptance.
### Browser administration
Cookie login/logout supports both roles. Administrators also have an explicitly opened Admin shell sharing the canonical dispatcher, and typed **Serial, Accounts/password/SSH authorized keys, Network, Display, Broker, SSH, and HTTPS/Reboot** settings. Serial/Wi-Fi/client/writer quick controls reuse the same settings controllers. Navigation preserves terminal sessions and serial writer ownership; explicit transfer is confirmed and generation-safe. Browser-shell permissions remain narrower than typed Settings or UART0/admin SSH.
Network uses UTF-8 text/reversible hex SSIDs, explicit password Keep/Replace/disabled-STA Clear (never AP clear), RAM edits and explicit Save. Saved secrets are never prefilled/exported. Next profile follows enabled priority order, not the editor's selected index. Network `accepted` means owner admission, not online/DNS completion; disruption may precede acknowledgement.
HTTPS and SSH identity settings display public fingerprints and confirm both service and identity generations before rotation. HTTPS rotation commits before stop/restart and invalidates web logins; SSH stops before commit/restart and can disconnect clients even when persistence fails. A committed identity is never rolled back on restart failure. A lost response or timeout is not cancellation: inspect state rather than automatically replaying. Verify changed trust on UART0 with `web certificate info` or `ssh host-key info` before renewing browser trust/known_hosts; HTTPS restart requires fresh login. Browser identity reset/recovery/export is excluded; canonical CLI recovery remains available.
Keep UART0 ready for administrative recovery and native USB for network-independent UART1 access. USB is not an admin console, and whole-device reboot interrupts every transport. [Web administration contracts](docs/web_administration.md) cover API bounds, ownership, permissions, uncertainty and recovery. Dedicated typed network diagnostics and the unimplemented browser-session/USB control expansion were removed from scope; existing shell diagnostics and SSH settings remain.
## Documentation
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
- [Electrical tests](docs/electrical_tests.md): OLED/buttons, MAX3243, UART loopback, and session-broker verification procedures.
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user provisioning and administration, HTTPS/SSH authentication, session revocation, and the planned integrated web-administration acceptance matrix.
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user provisioning and administration, HTTPS/SSH authentication, session revocation, and reusable integrated web-administration regression procedures (not execution evidence).
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [acceptance evidence](docs/web_administration_acceptance.md) records sign-off and telemetry limits.
- [Security hardening](docs/security_hardening.md): Phase 9 scope, crash/debug baseline, diagnostic handling, and pending validation.
- [Security operations](docs/security_operations.md): provisioning, explicit saves, rotation, recovery, reconstruction and approved destructive reset/decommissioning; procedures, not device evidence.
- [Dependency license inventory](docs/dependency_licenses.md): bounded local inventory and unresolved distribution/source/notice obligations, not release clearance.
- [wolfSSL/wolfSSH advisory review](docs/wolf_security_review.md): implemented scoped mitigations, historical research and remaining target/maintenance gates.
- [Remaining SSH parser review](docs/ssh_parser_remaining_review.md): completed finite server-profile dispatch/caller review, corrections and explicit exclusions; not exhaustive parser safety.
- [IDF applicability review](docs/idf_security_review.md): six named findings and a bounded advisory-index feature screen, not all-CVE clearance.
- [Bounded Phase 9 host validation](docs/phase9_validation.md): [orchestrator](tools/validate_phase9.py), 23 default commands; build and OpenSSH interop are separate opt-ins.
- [Exact icon provenance](docs/icon_provenance.md): official 7.4.47 retained SVGs verified; manual bitmap derivation and distinct mockup Wi-Fi path remain unresolved.
- [Release notice packaging](docs/release_packaging.md): offline [notice tool](tools/release_notices.py), deterministic bounded assembly and separate recipient/source/legal gates.
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
## Flash partition layout
@@ -32,15 +54,15 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, mDNS hostname, local-display, role-based user, HTTPS identity, and SSH host-key data |
| `otadata` | `0x089000` | 8 KiB | Active OTA-slot selection metadata |
| `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data |
| `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys |
| `coredump` | `0x08D000` | 128 KiB | Reserved for flash core dumps |
| `nvs_key` | `0x08C000` | 4 KiB | Unused; retained for layout compatibility (NVS encryption excluded) |
| `coredump` | `0x08D000` | 128 KiB | Unused by the supported crash policy; retained for layout compatibility |
| `ota_0` | `0x0B0000` | 4 MiB | Primary application/OTA slot |
| `ota_1` | `0x4B0000` | 4 MiB | Alternate application/OTA slot |
| `storage` | `0x8B0000` | 7488 KiB | Reserved for future LittleFS web assets, logs, and files |
Application offsets are aligned to the ESP32-S3's required 64 KiB boundary. The final storage partition ends at `0x1000000`, exactly the end of the 16 MiB flash chip.
The table reserves OTA and storage space; it does not implement OTA downloads, rollback confirmation, core-dump handling, NVS encryption, or filesystem mounting.
The table reserves OTA and storage space; it does not implement OTA downloads, rollback confirmation, NVS encryption, or filesystem mounting. Phase 9A disables new core dumps; it does not clear any existing `coredump` contents. The unused `nvs_key` and `coredump` reservations remain unchanged for layout compatibility; neither implies future enablement or secure erasure.
### One-time migration from the default partition table
@@ -52,7 +74,7 @@ pio run --target upload
pio device monitor -b 115200
```
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. Subsequent ordinary uploads do not need a full erase.
**Obtain explicit approval for the target and all flash/NVS data loss before erase.** This removes firmware, accounts, Wi-Fi secrets, server identities and all saved configuration, not just serial settings. Reflash in an isolated RF environment and follow [UART0-first provisioning](docs/security_operations.md#first-provisioning--uart0-first); generated defaults enable fallback Wi-Fi and require deliberate protection. Erase success is not a forensic secure-erasure guarantee. Subsequent ordinary uploads do not need a full erase.
## Build
@@ -60,6 +82,8 @@ This removes saved serial configuration and all other flash contents. The firmwa
pio run
```
The root configuration pins PlatformIO espressif32 **6.13.0**, ESP-IDF **5.5.3** (`framework-espidf@3.50503.0`), and Xtensa/RISC-V toolchains **14.2.0+20251107**, with pinned component sources. Exact version pins do not establish a complete immutable build closure. Nine C overrides plus one forced header retain the reviewed corrections, including five signed WS receive-size checks; see the [semantic rebase review](docs/idf_553_rebase_review.md). It generates audited security corrections under `.pio/build/` without changing the installed SDK/managed components; changed source hashes fail configuration rather than silently dropping a fix. See the [library review and upgrade contract](docs/security_library_review.md). Do not edit generated corrections or update hashes without reviewing the new source.
## Upload and monitor
Connect the board's **USB-to-UART** port for firmware upload and the UART0 development console, then run:
@@ -73,15 +97,21 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, or `mdns save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, 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.
Serial, Wi-Fi, mDNS hostname and display edits remain in RAM until explicitly saved with `serial save`, `wifi save`, `mdns save` or `display save`. These are same-device persistence commands, not backups; see [lifecycle and persistence semantics](docs/security_operations.md#working-configuration-versus-service-lifecycle). Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, TLS/SSH identity management, network diagnostics, and deferred reboot/SSH lifecycle commands. Create the first administrator on UART0 with `user add <username> admin` (optionally `--generate`). Explicit recovery of an unavailable user database remains UART0-only and rebuilds it empty; it refuses a healthy database. An administrator also cannot generate a replacement password for its own account over SSH, preventing the one-time value from being lost when that mutation revokes the session. Legacy web credential commands and `user bootstrap` are removed.
## Security notes
The HTTPS interface uses a device-specific self-signed certificate and 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.
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. Physical flash/RAM extraction and firmware replacement remain outside the threat model even after Phase 9. Secure boot and encrypted NVS are explicitly excluded; no flash/PSRAM encryption or physical JTAG eFuse restriction is promised.
SSH uses separate, boot-lifetime global admission budgets for handshakes and password/signed-key checks (burst six, one refill per ten seconds), and unsigned key probes (burst twelve, one per five seconds). Reconnect, SSH restart and counter clearing do not replenish them. Rate denial closes the authenticating connection without sleeping the owner task; the three-attempt per-connection failure limit remains. These global limits can temporarily deny legitimate new SSH logins under attack and do not promise fair access or zero CPU impact. Hidden console prompts reject overlong/unsupported input instead of silently accepting a prefix; consumed SSH admin staging bytes are wiped. See [security hardening](docs/security_hardening.md#9b-ssh-admission-and-credential-handling) for exact semantics and remaining review work.
Phase 9C limits HTTPS to TLS 1.2 ECDHE-ECDSA with AES-128/256-GCM, and SSH to AES-128/256-GCM with Curve25519/P-256 key exchange. Legacy-only clients may no longer connect; existing device identities and authorized keys do not need rotation. The HTTPS policy is server-local, leaving outbound-client defaults intact. Pinned dependency corrections address cleanup leaks, parser bounds and secret-bearing storage retirement; secure allocation growth can temporarily require old and new blocks, so target resource validation remains necessary. These fixes are not a complete secret-zeroization or upstream-advisory certification. See the [audit, exact algorithms and limits](docs/security_library_review.md).
The Phase 9A supported build baseline requires disabled core dumps and silent panic reboot, rejecting panic print/halt/GDBstub and software debugger-aware options at compile time. Development, test, and production are operational profiles of the same build baseline, not separate PlatformIO environments. Silent panic reboot sacrifices panic backtraces/register dumps; reset-reason/boot information and ordinary status/logging can remain. This is not a general log-redaction guarantee. Treat raw flash, RAM and dumps as secret-bearing; do not export them as routine diagnostics. No retroactive dump clearing or secure-erase claim is made. See [security hardening](docs/security_hardening.md) for the pending checks and reviewed synthetic-secret debugging procedure.
## 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`; see the [partial advisory review](docs/wolf_security_review.md) for scoped corrections and remaining review/target gates, not a whole-library safety claim. The [license inventory](docs/dependency_licenses.md) records open radio-blob corresponding-source/exception questions, notice delivery and preferred-source requirements, generated-file modification notices, and wolfSSH packaging clarification. Neither this license declaration nor the inventory clears firmware/device distribution.
### Legacy credential removal
+150
View File
@@ -0,0 +1,150 @@
# SPDX-License-Identifier: GPL-3.0-only
# Include after project(): IDF component targets and their final source lists exist.
if(CMAKE_VERSION VERSION_LESS 3.18)
message(FATAL_ERROR "Security overrides need CMake 3.18 source-property directory support")
endif()
set(_sak_security_script "${CMAKE_CURRENT_LIST_DIR}/../tools/security_overrides.py")
get_filename_component(_sak_security_script "${_sak_security_script}" REALPATH)
if(NOT PYTHON)
find_package(Python3 REQUIRED COMPONENTS Interpreter)
set(_sak_security_python "${Python3_EXECUTABLE}")
else()
set(_sak_security_python "${PYTHON}")
endif()
idf_build_get_property(_sak_security_idf IDF_PATH)
execute_process(
COMMAND "${_sak_security_python}" "${_sak_security_script}"
--idf-path "${_sak_security_idf}"
--project-dir "${PROJECT_SOURCE_DIR}"
--binary-dir "${CMAKE_BINARY_DIR}"
RESULT_VARIABLE _sak_security_result
OUTPUT_VARIABLE _sak_security_stdout
ERROR_VARIABLE _sak_security_stderr
)
if(NOT _sak_security_result EQUAL 0)
message(FATAL_ERROR "SDK security override generation failed:\n${_sak_security_stdout}${_sak_security_stderr}")
endif()
include("${CMAKE_BINARY_DIR}/security_overrides/manifest.cmake")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${_sak_security_script}" "${SAK_SECURITY_VERSION_HEADER}")
if(SAK_SECURITY_HEADER_IDS)
get_filename_component(_sak_security_tools "${_sak_security_script}" DIRECTORY)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${_sak_security_tools}/wolfssh_order/delta.json")
endif()
# Public extension point: the Python Entry registry supplies the mapping. This
# function is backend-agnostic; a later pinned project/vendor source uses it too.
function(sak_security_replace_source component original generated nested_target)
idf_component_get_property(_target "${component}" COMPONENT_LIB)
if(NOT TARGET "${_target}")
message(FATAL_ERROR "Security override: missing component target ${component}")
endif()
if(NOT "${nested_target}" STREQUAL "")
if(NOT component STREQUAL "mbedtls" OR
NOT nested_target MATCHES "^(mbedtls|mbedx509|mbedcrypto)$")
message(FATAL_ERROR "Security override: invalid nested target ${component}/${nested_target}")
endif()
if(NOT TARGET "${nested_target}")
message(FATAL_ERROR "Security override: missing nested target ${nested_target}")
endif()
set(_target "${nested_target}")
get_target_property(_imported "${_target}" IMPORTED)
get_target_property(_alias "${_target}" ALIASED_TARGET)
get_target_property(_owner_dir "${_target}" SOURCE_DIR)
get_filename_component(_owner_dir "${_owner_dir}" REALPATH)
get_filename_component(_expected_owner "${_sak_security_idf}/components/mbedtls/mbedtls/library" REALPATH)
if(_imported OR _alias OR NOT _owner_dir STREQUAL _expected_owner)
message(FATAL_ERROR "Security override: unexpected nested target owner ${_target}: ${_owner_dir}")
endif()
endif()
get_target_property(_source_dir "${_target}" SOURCE_DIR)
get_target_property(_sources "${_target}" SOURCES)
get_filename_component(_expected "${original}" REALPATH)
set(_matches 0)
set(_replaced)
foreach(_source IN LISTS _sources)
if(_source MATCHES "\\$<")
# An expression could hide an additional copy of the protected source.
message(FATAL_ERROR "Security override: unaudited source expression in ${component}: ${_source}")
endif()
get_filename_component(_absolute "${_source}" ABSOLUTE BASE_DIR "${_source_dir}")
get_filename_component(_absolute "${_absolute}" REALPATH)
if(_absolute STREQUAL _expected)
math(EXPR _matches "${_matches} + 1")
list(APPEND _replaced "${generated}")
else()
list(APPEND _replaced "${_source}")
endif()
endforeach()
if(NOT _matches EQUAL 1)
message(FATAL_ERROR "Security override: ${component} needs exactly one ${original}; found ${_matches}")
endif()
# Target flags/includes/definitions are retained because the target is not
# replaced. Preserve source-specific properties in the owning directory too.
set(_properties COMPILE_FLAGS COMPILE_OPTIONS COMPILE_DEFINITIONS
INCLUDE_DIRECTORIES OBJECT_DEPENDS OBJECT_OUTPUTS LANGUAGE
SKIP_PRECOMPILE_HEADERS SKIP_UNITY_BUILD_INCLUSION
SKIP_LINTING HEADER_FILE_ONLY)
set(_configs DEBUG RELEASE RELWITHDEBINFO MINSIZEREL
${CMAKE_CONFIGURATION_TYPES} ${CMAKE_BUILD_TYPE})
foreach(_config IN LISTS _configs)
string(TOUPPER "${_config}" _config)
list(APPEND _properties "COMPILE_DEFINITIONS_${_config}")
endforeach()
foreach(_property IN LISTS _properties)
get_property(_is_set SOURCE "${_expected}" DIRECTORY "${_source_dir}"
PROPERTY "${_property}" SET)
if(_is_set)
get_property(_value SOURCE "${_expected}" DIRECTORY "${_source_dir}"
PROPERTY "${_property}")
set_property(SOURCE "${generated}" DIRECTORY "${_source_dir}"
PROPERTY "${_property}" "${_value}")
endif()
endforeach()
get_filename_component(_original_dir "${original}" DIRECTORY)
get_property(_includes SOURCE "${generated}" DIRECTORY "${_source_dir}"
PROPERTY INCLUDE_DIRECTORIES)
# Restore the implicit quoted-include search directory lost by relocating C.
set_property(SOURCE "${generated}" DIRECTORY "${_source_dir}"
PROPERTY INCLUDE_DIRECTORIES "${_original_dir};${_includes}")
set_property(TARGET "${_target}" PROPERTY SOURCES "${_replaced}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${original}" "${generated}")
message(STATUS "Security override: ${component}: ${original} -> ${generated}")
endfunction()
# HandshakeInfo and WOLFSSH are ABI-sensitive. Force the same generated header
# into the library AND every transitive consumer, even if a consumer adds a
# vendor include directory ahead of the overlay. The original include guard
# then prevents a second, stale definition. This also creates compiler deps.
foreach(_sak_security_id IN LISTS SAK_SECURITY_HEADER_IDS)
if(NOT _sak_security_id STREQUAL "wolfssh_internal_header" OR
NOT SAK_SECURITY_${_sak_security_id}_COMPONENT STREQUAL "wolfssl__wolfssh")
message(FATAL_ERROR "Security override: unaudited header overlay")
endif()
idf_component_get_property(_sak_security_wolfssh wolfssl__wolfssh COMPONENT_LIB)
if(NOT TARGET "${_sak_security_wolfssh}")
message(FATAL_ERROR "Security override: missing wolfSSH overlay target")
endif()
target_include_directories("${_sak_security_wolfssh}" BEFORE PUBLIC
"${SAK_SECURITY_WOLFSSH_INCLUDE}")
# PlatformIO's ESP-IDF adapter sorts app flags and deduplicates component
# flags. Keep option+operand atomic, as with wolf_crypto_policy.h.
target_compile_options("${_sak_security_wolfssh}" PUBLIC
"-include${SAK_SECURITY_${_sak_security_id}_GENERATED}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${SAK_SECURITY_${_sak_security_id}_ORIGINAL}"
"${SAK_SECURITY_${_sak_security_id}_GENERATED}")
endforeach()
foreach(_sak_security_id IN LISTS SAK_SECURITY_OVERRIDE_IDS)
sak_security_replace_source(
"${SAK_SECURITY_${_sak_security_id}_COMPONENT}"
"${SAK_SECURITY_${_sak_security_id}_ORIGINAL}"
"${SAK_SECURITY_${_sak_security_id}_GENERATED}"
"${SAK_SECURITY_${_sak_security_id}_TARGET}")
endforeach()
+14
View File
@@ -0,0 +1,14 @@
# SPDX-License-Identifier: GPL-3.0-only
# Apply after project(). PUBLIC propagates the resolved-settings guard to every
# wolfSSL consumer, including wolfSSH and the application (ABI-sensitive keys).
idf_component_get_property(_sak_wolf_target wolfssl__wolfssl COMPONENT_LIB)
if(NOT TARGET "${_sak_wolf_target}")
message(FATAL_ERROR "wolf crypto policy: missing wolfSSL component target")
endif()
# Existing upstream checks, not a vendor-source backport. PUBLIC keeps library
# and consumer settings consistent without changing the root build file.
target_compile_definitions("${_sak_wolf_target}" PUBLIC
WOLFSSL_VALIDATE_ECC_IMPORT
WOLFSSL_ECDHX_SHARED_NOT_ZERO)
target_compile_options("${_sak_wolf_target}" PUBLIC
"-include${CMAKE_CURRENT_LIST_DIR}/wolf_crypto_policy.h")
+43
View File
@@ -0,0 +1,43 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef SAK_WOLF_CRYPTO_POLICY_H
#define SAK_WOLF_CRYPTO_POLICY_H
#include <wolfssl/wolfcrypt/settings.h>
/* PR10133 recommends this existing check for older releases. In 5.8.2 the
* software validator must not be replaced with a successful hardware stub. */
#if !defined(HAVE_ECC) || !defined(WOLFSSL_VALIDATE_ECC_IMPORT) || \
!defined(HAVE_ECC_CHECK_KEY)
#error "wolf crypto policy: ECC requires validated imports"
#endif
#if defined(NO_ECC_CHECK_PUBKEY_ORDER) || defined(WOLF_CRYPTO_CB_ONLY_ECC) || \
defined(WOLFSSL_ATECC508A) || defined(WOLFSSL_ATECC608A) || \
defined(WOLFSSL_CRYPTOCELL) || defined(WOLFSSL_SILABS_SE_ACCEL) || \
defined(WOLFSSL_SE050) || defined(WOLFSSL_STM32_PKA)
#error "wolf crypto policy: review ECC validation backend before changing it"
#endif
#ifndef WOLFSSL_ECDHX_SHARED_NOT_ZERO
#error "wolf crypto policy: X25519 requires all-zero shared-secret rejection"
#endif
/* PR9275 selects small math on Xtensa to avoid compiler-introduced timing
* differences. Check resolved settings, not just command-line intentions.
* https://github.com/wolfSSL/wolfssl/pull/9275
*/
#if !defined(HAVE_CURVE25519) || !defined(CURVE25519_SMALL)
#error "wolf crypto policy: X25519 requires CURVE25519_SMALL"
#endif
#if !defined(HAVE_ED25519) || !defined(ED25519_SMALL)
#error "wolf crypto policy: Ed25519 requires ED25519_SMALL"
#endif
/* 5.8.2 excludes small math from automatic blinding and rejects this pairing.
* Do not force blinding back on: key layout and function signatures differ.
*/
#ifdef WOLFSSL_CURVE25519_BLINDING
#error "wolf crypto policy: small X25519 is incompatible with blinding"
#endif
#if defined(HAVE_CURVE448) || defined(HAVE_ED448)
#error "wolf crypto policy: review PR9275 small math before enabling 448"
#endif
#endif
+1 -1
View File
@@ -58,7 +58,7 @@ dependencies:
idf:
source:
type: idf
version: 5.5.0
version: 5.5.3
wolfssl/wolfssh:
component_hash: 24d623360c07374a90b1ade8d1218b24bb7b661fd51ace9135dccf510a338927
dependencies: []
+35 -44
View File
@@ -23,6 +23,10 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
(does not join the broker)
```
## Typed Network settings
`web_network_settings` admits bounded current-admin operations to the existing ID-only dispatcher; `wifi_manager` remains radio/event/reannouncement owner and `mdns_service` owns independent hostname persistence. Zero-wait secret-free projections never copy saved PSKs onto HTTPD. Conditional compare/merge preserves omitted secrets, queue admission precedes Wi-Fi publication, and edits require explicit Save. Wi-Fi Load is stored-only; mDNS may load deterministic defaults. SSIDs remain byte-reversible; AP clear is denied. One login-bound slot and one-second timer bound queued secret retention to 30 seconds plus scheduling latency, not cancellation after owner admission. `accepted` is not online/DNS completion. See [Network contracts](../web_administration.md#network).
## Startup and initialization
`app_main()` in `src/main.c` is the composition root. The implemented order matters:
@@ -35,7 +39,7 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
6. Load/generate HTTPS material, then initialize the independent user database, committing an empty database when storage is missing. User-database failure makes network authentication fail closed.
7. Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task.
8. Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions.
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. Both gates also require HTTPS security readiness, and SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key.
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects `main.c` after accepted legacy-credential cleanup.
10. Start the local status/control task if button initialization succeeded.
11. Construct ESP-IDF's UART REPL to initialize `esp_console`, but do not start the stock REPL task. Register command groups, install completion, and start the custom UART frontend that feeds the shared dispatcher.
@@ -74,6 +78,10 @@ UART RX -> serial-service RX stream -> broker task
The broker drains serial input even with no clients. A full client output stream drops only that client's copy and updates drop counters; it does not block UART reception or other clients.
Binary serial WebSocket output uses the IDF-5.5.3-pinned `web_httpd_ws_send_binary` adapter: one owner-only session-override send of a bounded header+payload copy (516 bytes of local scratch, 512-byte payload). Existing generation validation and one outstanding work item per slot remain required. Non-full sends immediately replace that session's send override with a reject-only guard, mark it closing and shut down the socket before deferred cleanup; this prevents automatic SDK control replies from reentering TLS after incomplete output. HTTPD retains TLS destruction ownership. Text/control and admin output retain the SDK sender. One send call does not imply one TLS record, packet or peer receipt; the user signed off drop-free230400-baud full-client-mix operation at160MHz. Runtime stack-margin validation remains a separate follow-up; evidence and acceptance scope are in `current-state.md`.
Active-client counter snapshots expose ID/type/pending/HWM/UART/queued/read/dropped. HWM is maintained independently of web tracing and counter clear seeds current occupancy. Read means transport handoff, not peer receipt. Disconnect removes the client row while global totals retain traffic and unread-output discards. Independent default-disabled web performance capture uses two fixed slot records and nonwrapping epoch/generation fences; toggles fence in-flight samples, disable freezes aggregates, clear preserves enable state. Binary-only timestamps bound reservation-entry to callback-entry before locking, synchronous send calls and completion to broker-read return; idle and intervening work prevent scheduler-only/backlog-at-completion claims. No new instrumentation allocations or scheduling/buffer changes. Exact fields, overhead and capture contracts: [throughput diagnostics](../web_throughput_diagnostics.md).
### Clients to RS-232
```text
@@ -96,43 +104,31 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
### HTTPS, WebSocket, and web serial
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade.
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. Phase 9C uses exact-hash build-tree SDK corrections for failed-start/post-handshake TLS cleanup, copied-key wiping, HTTPD scratch retirement and TLS1.2 ECDHE-ECDSA AES-GCM-only server policy. Client defaults/global crypto are unchanged. The checked-in override registry plus pinned original, not installed source alone, define compiled behavior. [Source/ownership contract](../security_library_review.md). `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
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.
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. A non-consuming quota/epoch check rejects exhausted login requests before body receive; verification reservation remains post-parse and only that reservation charges the existing fixed window. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
`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_httpd_adapter` is the sole private IDF 5.5.3 boundary for duplicate headers, admission-before-101, consumed-scratch wiping, staged optional URI registration, combined binary sends and owner-only idle sweeps. Re-audit its version guard on SDK upgrades. The 5.5.3 rebase retains private-layout/ownership contracts and adds a pinned WS receive correction so negative fixed-header reads cannot pass unsigned length comparisons; see [rebase evidence](../idf_553_rebase_review.md). HTTPD debug logging must not expose headers/tickets. `web_diagnostics` independently observes public post-TLS callbacks using six metadata records and a default-disabled 32-event ring; it cannot see preaccept/in-progress/failed TLS. [Admission diagnostics](../web_admission_diagnostics.md).
`web_session_store` holds four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. These are live cookie sessions in 8D.3, with no sliding renewal. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Authentication/store-init failure now gates HTTPS startup rather than falling back to Basic. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in `tests/web_session_store/`.
`web_httpd_idle` uses one one-second timer, six rows and at most one queued owner probe. Current-owner shutdown follows 15 seconds of observed ordinary idle, exempting actual WebSockets/async/pending input. Owner delays prevent hard timeout guarantees; accepted-but-lost work stays reserved until successful destruction, failed stop retains ownership. [Idle lifecycle contract](../https_idle_cleanup.md).
Serial tickets/slots bind to distinct originating web-session IDs; 8D.3 rejects zero instead of treating it as Basic. Trusted internal mint/upgrade callers supply the ID; bound checks also compare the session's copied principal, with no CSRF export. Mint/consume/admission/input and existing 250 ms owner checks validate session liveness/currentness. Session-specific transport revocation invalidates the store first, then clears matching tickets and flags matching reserved/active slots for existing HTTPD/broker cleanup. Account/global transport revocation now invalidates cookie records even if serial initialization failed; existing console mutation callers reach these hooks unchanged. A non-wrapping transport epoch cancels in-flight ticket publication across revocation and server detach/re-attach. Store/database checks remain authoritative if notification is missed. 8D.3 activates these checks for all browser routes, with five added authentication handlers (14 total), unchanged six HTTPS sockets and no new task.
`web_ui`/`web_login_ui` own authored documents/scripts and hash-bound CSP loaders; authentication documents/app are no-store. Checked-in generated xterm/logo assets are compiled, not regenerated by ordinary builds. Browser Serial/Admin/Settings navigation changes view/input only, preserving serial client/lease and hidden output draining. Session-identity changes require a clean document; pagehide/restore revalidates before exposing buffers. One shared quick-settings host/controller preserves drafts, stale selections and pending uncertainty. [Terminal and console contract](../web_administration.md#terminal-and-console-ownership).
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; expired/stale identities are reclaimed and live capacity is rejected with 503/Retry-After, not eviction. Ticket issuance and upgrade require `Origin` matching validated Host after host-case/default-port normalization; missing Origin fails even for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts.
### Browser administration and HTTPS lifecycle
`web_httpd_adapter` is the sole private ESP-IDF 5.5.0 boundary. Its compile-time version guard requires review on upgrades. It validates NUL-separated parsed headers because public getters return only the first field, and rejects duplicates/ambiguous framing. The serial URI is registered as ordinary HTTP GET so cookie/ticket/principal/broker admission precedes explicit 101 and frame-handler installation; automatic IDF WebSocket routing would send 101 too early. Cleanup wipes consumed scratch but preserves right-aligned unread pending data. CMake compiles HTTPD logs above ERROR out to prevent header/ticket logging. No SDK patch or component copy exists. See `docs/phase8d3_implementation.md` for source verification, tests and pending on-wire checks.
`web_admin_transport`/`web_admin_tickets` add one optional admin socket, two session-bound tickets and admission to the same two remote-console slots shared with SSH, never a broker client. HTTPD owns a 1,552-byte PSRAM-only payload and IO; a 20 ms timer queues at most one poll. Current-owner shutdown avoids queued reusable socket pointers. Detach fences submissions and only successful HTTPD stop retires old work. Console/owner checks enforce currentness before sensitive work; unsupported parsed shell commands reject before effects. [Browser-shell policy](../web_administration.md#browser-shell-policy).
`web_diagnostics` observes public synchronous HTTPS create/close callbacks without replacing socket/transport cleanup, and wraps only the four serial/admin ticket/upgrade handlers. Six always-maintained post-TLS metadata records supply a console-safe occupancy snapshot without querying HTTPD off-owner; an opt-in 32-entry numeric ring adds timing/heap/HTTPD stack samples. Firmware-lifetime connection sequences and capture epochs survive clear/restart and fence fd reuse/toggle races. No tasks/probes/event subscriptions; no authentication/request data retained. UART0/admin SSH commands never wait for HTTPD; browser policy remains unchanged. This is successful-TLS occupancy, not preaccept or failed/in-progress handshake instrumentation; exact limits/overhead in `docs/phase8d11_implementation.md`.
`web_lifecycle_settings` uses one original-login slot and send-return → nonreused-ID HTTPD callback → existing dispatcher for self-cutting HTTPS/reboot actions. Two-second ACK and 30-second dequeue bounds precede admission, not receipt/completion. Accepted-but-lost callbacks retain one reservation through failed stop; only callback/successful destruction retires it. Conditional stop/restart reserve saturated lifecycle generation; restart retains ownership through stop/start. Conditional reboot invokes canonical `esp_restart()` outside locks, never HTTPD self-stop or console-cleanup waits.
Ordinary HTTPS idle retention is independently enforced by `web_httpd_idle`: one persistent one-second ESP timer, at most one generation-qualified HTTPD work reservation and six owner-only rows. The private adapter observes IDF's all-route successful `req_new`/`req_delete` completion marker, checks actual WS/async flags and pending/readable input, and shuts down only the current expired ordinary fd after 15 seconds of observed idle. TLS-create resets reused-fd observations before diagnostic publication. No TLS cleanup override, LRU eviction, in-progress request interruption or diagnostics dependency. Stop fences submissions before destroying HTTPD; only successful stop retires queued state. Slow owner work and accepted-but-lost nonblocking UDP work preclude a hard wall-clock guarantee; loss stays bounded until successful restart. Timer preparation failure gates HTTPS start. SDK audit, tests and exact limits: `docs/https_idle_cleanup.md`.
Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes.
`web_ui.c` contains authored index/application strings and response policy; it validates `/api/session` before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. `web_login_ui` is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
### Browser admin backend
8D.8 adds an in-document admin-only Settings/Serial view and optional `GET /api/settings/serial`. It requires current cookie/principal/admin authorization, rejects bodies/queries and inherits ordinary-GET Origin/no-store policy. Eight working config/running fields fit a 256-byte response; `serial_service_get_snapshot()` takes the existing state mutex with zero wait, releasing it before encoding/send and returning unavailable on contention. No mutation, NVS, broker or socket-lifecycle operation occurs. Both hidden terminals continue draining; Settings input is disabled, refresh is explicit/single-flight with session identity checks that cannot supersede serial admission, and cancellation clears/fences the settings view. URI budget is now 17, sockets remain six/no LRU. The private adapter's startup-only exact-GET registration stages descriptor/name allocation before table publication, unlike installed IDF's public failure path; HTTPD retains normal free ownership. Only Settings uses that helper; existing registration callers remain unchanged. See `docs/phase8d8_implementation.md` for bounds, tests and target-pending evidence.
The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling.
8D.6 `web_ui.c` now supplies the admin-only Serial/Admin selector using this backend. Selection leaves serial and any open admin socket connected and draining; only focus, displayed terminal and keyboard destination change. Broker identity/lease and writer controls remain serial-owned in both views. Admin opens/reopens explicitly and closes independently. Two page-lifetime terminals have separate 5,000-line scrollbacks and 64 KiB callback-accounted pending output each; browser overflow is dropped with visible counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Logout/expiry/page exit closes both with generation fencing and socket-listener cleanup; bfcache revalidates serial/session but never automatically reopens admin. No server policy/capacity changes or 8D.7 lifecycle parity. See `docs/phase8d6_implementation.md`; the following paragraph describes the original backend boundary, before its UI entry was added.
8D.5 additionally supplies `web_admin_transport` and `web_admin_tickets`: one optional admin socket, two 30-second digest-only tickets bound to current originating session/principal, the same two shared console slots, no serial broker client. Ticket POST requires cookie/Origin/CSRF/admin; ordinary GET upgrade requires cookie/Origin/admin/ticket and console admission before 101. Six total HTTPS sockets remain, LRU purge is disabled, and two routes bring the handler budget to 16. Optional admin registration/PSRAM allocation failures do not take down M1. A 20 ms ESP timer queues at most one HTTPD poll, with no new task; only HTTPD accesses the 1,552-byte PSRAM-only RX/TX payload or socket IO. Notifiers close the generation-qualified console and flag the socket. HTTPD shuts down the verified current fd directly and owns subsequent read cleanup, avoiding IDF's queued reusable `sock_db *` close race. Detach fences submissions; failed stop retains ownership, and queued state is retired only after successful HTTPD stop. Console dispatcher/prompt and owner input/output/idle checks enforce session and principal currentness. WEB supports deferred self-close only; parsed canonical policy denies unsupported lifecycle/network/account mutations before handler side effects. No normal UI entry, typed settings or lifecycle parity is included. See `docs/phase8d5_implementation.md` for validation limits and exact restrictions.
`web_server_replace_identity` reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. [HTTPS ownership, generation and recovery contract](../web_administration.md#https-and-reboot).
### SSH
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers. Phase 9B adds owner-only boot-lifetime token buckets for handshake admission, password/signed-key checks and unsigned probes; reconnect/service restart/counter clear do not reset them. Rate rejection closes the authenticating connection without sleeping the owner. Global starvation remains a tradeoff; see [admission policy](../security_hardening.md#9b-ssh-admission-and-credential-handling).
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Phase 9C applies explicit GCM/Curve25519/P-256 algorithm lists before context publication; policy failures discard the candidate. A source-pinned parser correction bounds password fields before callbacks and wipes the method payload afterward (synchronous project callbacks). Global wolfSSL memory hooks wipe retired usable allocations; shrink retains capacity, growth may require old and new blocks simultaneously. These hooks do not replace mbedTLS allocation. [Policy/limits](../security_library_review.md). Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`. A pending-result marker gates exactly-once signed-key completion/currentness; the reviewed wolfSSH version/feature profile is guarded and keyboard-interactive has an explicit rejecting callback (advertisement is not a dispatch filter). Consumed admin staging bytes and retired slots are wiped; this is not a full library-memory wipe guarantee.
Routing follows the authenticated role:
@@ -156,19 +152,11 @@ The final administrator cannot be deleted or demoted. UART0 establishes the firs
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 and Serial settings
**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.
`web_account_settings` owns one login-bound slot for create/password/role/delete/key operations; HTTPD reads compact zero-wait metadata and queues only IDs. The database compares target username/account-ID/auth-generation inside its mutation lock and shares canonical invariant/commit logic. Successful commands target-revoke, including self; result loss is uncertain. A one-second timer wipes queued non-executing credentials after 30 seconds plus scheduling latency; admitted work wipes locals on return. Generated password delivery is a separate no-retained-retrieval POST, not mutation; key listing is fingerprint-only with stable sparse indices. [Accounts contracts](../web_administration.md#accounts-and-authorized-keys).
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):**
`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)
`web_serial_settings` admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. `/api/status` also uses the zero-wait serial snapshot and emits `running:null` when unavailable. See `docs/phase8d9_implementation.md` for resource and target-pending evidence.
`web_serial_settings` queues bounded typed operations to the same dispatcher, retaining one original-login result and a 30-second dequeue check. Apply/Defaults are RAM-only; Save persists device working state. Reconfiguration can discard serial-service pending bytes while broker clients/lease/output remain. Snapshot reads are zero-wait and consistent; `/api/status` reports unavailable running state as null. Settings navigation preserves both terminals; bounded completion checks and manual uncertainty recovery never replay mutations. [Typed API/lifetime](../web_administration.md#typed-settings-api-and-operation-lifetime).
## Console architecture
@@ -185,17 +173,13 @@ admin SSH line editor ----/ |
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
The 8D.4/8D.5 boundary retains `admin_ssh_console_open_owned()` and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner `is_current` checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
The transport-neutral boundary retains `admin_ssh_console_open_owned()` and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner `is_current` checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
The first 8D.7 slice enables browser-admin reboot and HTTPS stop through the same control task. `web stop` is deferred only for browser origin; UART0/admin SSH keep their synchronous HTTPS-stop path. WEB performs authoritative cookie/principal/token validation after drain and delay, then calls lifecycle APIs outside console locks, never socket IO. Console snapshots expose pending deferral; HTTPD discards buffered/new input observed during it and latches each frame's discard decision across payload reception/cancellation. HTTPS stop intentionally closes both browser routes.
The second 8D.7 slice additionally permits exact parsed browser `web certificate rotate --force`. The request queue has a typed command-line/deferred-action union with unchanged capacity. An immutable owner `dispatcher_actions` mask sends certificate work, after the bounded drain and 200 ms delay, nonblockingly to the existing 12 KiB dispatcher rather than the 4 KiB control stack; zero mask retains SSH control-task behavior. Pending input remains gated through queueing/execution. Dispatcher token/principal/session/owner revalidation and an executing-slot reservation prevent stale execution or reuse during self-detach; WEB validates currentness again before lifecycle APIs. Transactional certificate generation/persistence commits before stop → start; generation/commit error skips lifecycle calls, stop error skips start and retains HTTPD ownership, and later lifecycle failure does not roll back committed material. HTTPD alone owns socket IO. Successful restart invalidates browser sessions and both routes; certificate trust and login must be renewed, while USB/UART0/SSH remain available. Account/legacy-credential/network/restricted SSH mutations remain blocked. No new tasks, depths, routes, assets or stack sizes. Drain/acknowledgement bounds are not execution deadlines or delivery guarantees; owner-mask/local-scratch target layout and control/dispatcher stack margins remain unmeasured (host sizeof is not target proof).
The third 8D.7 slice permits browser other-account interactive add/password and forced delete/role mutations, with shared parsed policy at dispatcher admission and canonical-handler defense. Self-target, generated-secret, key, bootstrap and recovery workflows remain blocked. Account/owner/session/token currentness is checked after password prompts and before database API operation admission. This is operation-admission currentness, not an atomic liveness/NVS-commit guarantee: an admitted derivation/mutation may finish and target-revoke after disconnect or expiry; subsequent stale operations reject. Reconnecting administrators must inspect uncertain account outcomes rather than assume cancellation. Existing transaction cleanup, account invariants and best-effort targeted notifications remain unchanged.
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed, but browser self/generated/key/recovery and restricted network/SSH shell actions are not; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. [Browser policy and recovery](../web_administration.md#browser-shell-policy).
## Wi-Fi and persistence
@@ -215,6 +199,12 @@ Persistent namespaces/blobs include:
Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in `partitions.csv`; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented.
## Typed Display and Broker settings
`web_display_settings` queues IDs; `local_status_ui` owns a nonwrapping configuration generation and zero-wait writer reservation shared with CLI/legacy Apply. NVS runs outside critical sections. Save stabilizes RAM, Load preserves fallback behavior, Reset commits before RAM publication. Buttons/diagnostic holds change independent activity state, not config generation. Settings need an available UI task, not an attached OLED. [Display contract](../web_administration.md#serial-and-display).
`web_broker_settings` exposes compact zero-wait rows and confirmed writer assignment through one login-isolated slot. The broker atomically snapshots clients/writer/lease version and compares selected target/version inside the force-writer lock before effects. Three-bit slot/29-bit client generations retire rather than wrap; a separate saturated 32-bit lease generation advances before advisory event delivery and survives counter clear. Saturation blocks conditional assignment, not ordinary request/release/disconnect/recovery force. Contextual refresh never silently rebases explicit selections or clears sticky stale/absence latches; deliberate reselection is required. [Broker/context contracts](../web_administration.md#broker-and-contextual-controls).
## Local UI and hardware boundaries
`board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
@@ -234,10 +224,11 @@ Hardware diagnostics are synchronous console commands. RS-232 tests own the phys
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path.
- 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.
- Ping's 21-event payload (4,200 bytes) and the public user-console snapshot (2,156 bytes) are lazy PSRAM-only allocations retained for firmware lifetime, with no internal fallback. Queue control and synchronization stay internal; only ping or user status/list/show fails on allocation failure, never registration, mutations or UART0 recovery. Commands retain dispatcher/gate serialization; ping callbacks are task-context producers. The entire user snapshot is wiped after each display attempt. Never introduce ISR/cache-off access or free payloads while callbacks can retain them. Regression coverage: `tests/admin_console_boundary/psram_ping.py` and `accounts.py`.
- 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`.
`web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: [legacy compatibility](../legacy_credential_removal.md).
+62 -37
View File
@@ -12,6 +12,29 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Dependencies: every subsystem initializer
- Lifecycle constraint: optional display/network failures should not remove UART0 administrative recovery or USB UART1 access; the custom administration frontend starts only after command registration.
## Crash/debug build policy
- Files: `src/security_build_policy.c`, registration in `src/CMakeLists.txt`, diagnostic flags in `sdkconfig.defaults`; tests: `tests/security_build_policy/run.py` (optional `--sdkconfig-header` checks the generated configuration).
- Compile-only guard: require no core dumps and silent panic reboot; reject panic/register output, panic/runtime GDB stubs and OCD-aware panic handling. No runtime allocation/task or physical JTAG restriction. Policy, operational profiles and target gates: [Phase 9 hardening](../security_hardening.md).
## Source-pinned dependency corrections (Phases 9C9D)
- Files: root `CMakeLists.txt` (after `project()`), `cmake/security_overrides.cmake`, `tools/security_overrides.py`; tests: `tests/sdk_security_overrides/run.py --build-dir .pio/build/esp32-s3-devkitc-1-n16r8`.
- Build input is the exact-hash original **plus checked-in edits**, not installed source alone. Generated copies replace nine target C sources plus one PUBLIC forced header without modifying SDK/managed components: HTTPS cleanup/private-key release, HTTPD scratch lifetime/null first read, ESP-TLS server-only protocol list, wolfSSH password bounds/payload wiping plus bounded IGNORE/service/string parsing, channel-window overflow rejection and ECC/Ed25519 label/exact-signature framing, DHCP option bounds (CVE-2026-45160), TLS 1.2 EMS error propagation (CVE-2026-50581), X.509 OID allocation failure (CVE-2026-34874), and five signed WS fixed-header size comparisons on IDF 5.5.3 (982 cases / 10 mutation checks against generated code). The mbedTLS entries explicitly select validated nested `mbedtls`/`mbedx509` targets, not the component wrapper. Original notices and compile properties retained; all outputs carry the 2026-09-15 baseline modification notice and wolfSSH outputs add the 2026-09-16 ordering/provenance notice; source/hash/target ambiguity fails configuration. Never hand-edit generated copies or silently repin.
- Xtensa crypto policy: root `CMakeLists.txt` sets `CURVE25519_SMALL`/`ED25519_SMALL` before component parsing; `cmake/wolf_crypto_policy.cmake` PUBLIC-propagates `cmake/wolf_crypto_policy.h` to wolfSSL consumers. PUBLIC `WOLFSSL_VALIDATE_ECC_IMPORT` and `WOLFSSL_ECDHX_SHARED_NOT_ZERO` enable existing P-256 import and X25519 all-zero-result checks. The resolved-settings guard requires these checks and both small implementations, rejects reviewed ECC validator-disabling/hardware-stub configurations, X25519 blinding and unreviewed Curve448/Ed448 enablement. Tests: `tests/wolf_crypto_policy/run.py` (strict actual production flags, real vendor crypto/ASN vectors and independently specified exact source deltas; candidate injection is not production evidence). [Key-validation review](../ssh_key_validation_review.md) records effective flags, caller/API limits and unmeasured validation cost. [Wolf review](../wolf_security_review.md) distinguishes implemented restricted ordering/crypto/parser mitigations from pending target gates and explicit dormant-profile exclusions; [IDF review](../idf_security_review.md) records three backports, finite six-finding applicability completion and first-page feature exclusions, not exhaustive advisory safety.
- Parser tests: `tests/wolfssh_parser_contract/run.py`, [scope and exclusions](../../tests/wolfssh_parser_contract/README.md): 3,258 base cases per each of two stack modes plus five channel profiles in both modes (2,737/profile/mode for TERM, TERM+SHELL, TERM+SHELL+AGENT; 2,735 for neither/SHELL-only); 11 base + 18 name/length + 2 application-gate mutations rejected. Guard pages/UBSan and crypto doubles test parsing/gating, not arithmetic. [Finite review](../ssh_parser_remaining_review.md) closes exact CHANNEL_FAILURE recipient, ssh-userauth service and nine channel-name dispatch checks; PR899 client skips remain unchanged behind role/ordering gates, forwarding disabled, generic caller trace dispositioned but APIs unpatched. Unknown/trailing behavior is retained; not exhaustive safety. Ordering has its separate suite below.
- Historical IDF 5.5.0 supplied parent `pio run` PASS: **94,340 B linked RAM / 1,768,901 B flash (+200 B versus 1,768,701 B)**. Final parent **all seven suites PASS**: ordering `--interop` (8,028 checks / seven rejected mutations / 12 exact-256-KiB sessions, clean channel close and transport EOF), SDK overrides `--build-dir .pio/build/esp32-s3-devkitc-1-n16r8`, auth (135), protocol, strict crypto, notices (30), and parser (3,258 × two modes plus channel profiles; 11 + 18 + 2 rejected mutations). Interop used unsandboxed approval only for local AF_UNIX sockets; no remote network/device operation. Review's misplaced EOF guard is corrected in `SendChannelEof` before lookup/serialization/state mutation; follow-up verification found no scoped blocker. Not target/runtime-reserve evidence.
- Restricted **CVE-2025-14942** ordering correction implemented: `tools/wolfssh_order/delta.json` supplies exact executable edits to `internal.c`, `ssh.c` and `internal.h`; [README](../../tools/wolfssh_order/README.md) records PR793/819/840/855/921 prerequisite disposition, and `provenance.json` pins archived patch hashes/commit IDs. Existing X25519/P-256 KEX only; independent SELF/PEER bits, expected-message/auth gates and exactly-once queued NEWKEYS under WANT_WRITE. **EXT_INFO intentionally disabled, no `server-sig-algs`; `extInfoSent` stays zero.** No full upstream backport or dependency upgrade. CMake propagates the generated ABI header BEFORE PUBLIC plus PUBLIC forced include with stale-header rejection; joined `-include/path` flags preserve ordering/crypto guards through PlatformIO sorting/deduplication.
- Ordering tests: [tests/wolfssh_order_contract](../../tests/wolfssh_order_contract/README.md), full generated C/real wolfCrypt, **8,028 checks / seven rejected mutations**; `pio_adapter.py` validates installed PlatformIO/SCons flags with a real Xtensa consumer. Initial interop failed a harness close race despite early PASS output; fixed harness awaits peer close and transport EOF and independently reaps the server via fd-passing proxy. Final agent `--interop --interop-repeat 3`: **36/36 sessions**, exact **256 KiB** each, **two or ten** key exchanges and clean client/server exits. [Remaining gates](../wolf_security_review.md#restricted-ordering-correction-and-remaining-gates): target cleanup during rekey, no-EXT_INFO client compatibility, resource/timing and whole-phase validation; no device operation or phase sign-off.
- [Finite dependency advisory snapshot](../dependency_advisory_coverage.md): all 22 IDF index entries across three pages plus named managed/browser channels accounted for; declared search complete, not exhaustive clearance. **Fix-bearing Wi-Fi vendor bundle integrated in the validated IDF 5.5.3 candidate; radio-hardware closure pending**; [Wi-Fi update plan](../wifi_security_update_plan.md) requires coherent vendor framework/toolchain/source/header/binary ABI bundles and override revalidation, not PMF/WPA3 weakening. Residual applicability questions and target gates remain.
- [Bounded Phase 9 runner](../../tools/validate_phase9.py), [usage/evidence](../phase9_validation.md), fixtures `tests/phase9_validation/run.py`: 23 default commands; build and OpenSSH interop are independent opt-ins. After the owning agent fixed the stale console-boundary anchor, final supplied parent `CCACHE_DISABLE=1 python3 -B tools/validate_phase9.py` **PASS 23/23** and orchestrator fixtures **PASS 10 tests**. That default run is historical. Current supplied parent explicit-candidate validation with `--interop --web-performance` **PASS 24/24**; [exact command and pre/post source equality](../phase9_validation.md#current-candidate-execution--2026-09-18). Fresh isolated `app-validated` build PASS: **95,552 B RAM / 1,749,493 B flash (+1,212 / 19,408 B)**. Default root build timed out at 200 seconds during installation before compilation, not a root build PASS. Root now pins platform 6.13.0 / IDF 5.5.3 / both toolchains 14.2.0+20251107; [integration](../idf_candidate_integration.md), [semantic rebase and resolved historical WS blockers](../idf_553_rebase_review.md). Version pins are not complete immutable dependency closure or phase acceptance.
- Policy/evidence/limits: [library review](../security_library_review.md), [Phase 9C](../security_hardening.md#9c-library-cleanup-and-protocol-policy). Source-contract tests must locate and verify actual generated compilation inputs, not assume original vendor paths.
## Offline release notice assembly
- [Procedure and release gates](../release_packaging.md), [tool](../../tools/release_notices.py), catalog `third_party/release-notices/inputs.json`, scope/TLSF provenance under `third_party/release-notices/`; tests: `tests/release_notices/run.py` (supplied agent 36 fixture tests PASS). 75 explicit hash/size-pinned inputs (nine IDF/toolchain notice entries semantically rebased for the candidate; the other 66 unchanged); no recursive collection, fetch, build, asset regeneration or device access; output must be fresh and outside input roots.
- Supplied agent evidence: two actual 75-input bundles verified deterministic, each **77 files / 4,433,930 bytes**; no reassembly here. [Exact retained SVG provenance](../icon_provenance.md) resolves official 7.4.47 sources at `9e04201d4557e729822fb57f62a316c3dea1d4a8` (Google USB, Simran Wi-Fi); manual bitmap derivation and distinct mockup Wi-Fi path remain unresolved. The catalog pins `docs/icon_provenance.md`, not this code map or the edited overview docs; coordinate changes to pinned evidence, never silently repin. Recipient delivery, corresponding source, legal/radio-blob basis and whole-phase target gates remain open. A notice manifest is not a source archive, complete SBOM or release clearance.
## Secure randomness
**Responsibility:** provide the sole project-owned, mutex-serialized application DRBG, seeded before Wi-Fi/radio use.
@@ -37,12 +60,13 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** mediate all transport access to the serial service; provide one writer lease and multiple isolated observers.
- Files: `src/session_broker.{h,c}`, `src/session_console.{h,c}`
- Interfaces: connect/disconnect, request/release/force writer, nonblocking read/write/event APIs, snapshots and counters
- Interfaces: connect/disconnect, request/release/force writer, nonblocking read/write/event APIs, snapshots and counters; `session_broker_get_management_snapshot()` and `session_broker_assign_writer_current()` atomically project/compare target and lease generation. Exhausted 29-bit client generations retire; saturated lease generation preserves ordinary recovery. Tests: `tests/session_broker_diagnostics/run.py`.
- Called by: USB, web serial, role-`user` SSH, console tests, local UI snapshots/actions
- Dependencies: `serial_service`
- Data path: `transport -> broker -> serial service -> UART1`; reverse data is fanned out per client.
- Ownership: client IDs are slot/generation-safe; events are advisory and can drop, so use snapshots as authority.
- Lifecycle: one permanent task and eight preallocated client slots; slow output drops only for the affected client.
- Diagnostics: `broker counters` adds active-client ID/type/pending/HWM/UART/queued/read/dropped rows; clear seeds HWM from pending, disconnect removes rows but retains global discard accounting. `tests/session_broker_diagnostics/run.py`; capture before disconnect, never use consuming `broker read` as a probe. Semantics/recipe: `docs/web_throughput_diagnostics.md`.
## Native USB CDC
@@ -58,15 +82,17 @@ This is a semantic map, not a complete file inventory. Start here, then read the
## Web and WebSocket serial
- **8D.11:** `web_account_settings.{c,h}` extends Accounts with fingerprint-only POST `/api/settings/accounts/keys` and key-add/key-delete/key-clear on the existing operation endpoint/dispatcher. `user_database.{c,h}` owns zero-wait target-checked snapshots and canonical conditional key mutations. `web_ui.c` handles confirmations, sparse stable indices and self-revocation uncertainty. 24 handlers, six sockets; no new task/stack/queue depth. Host-tested/build-verified, target pending. Contracts/tests/checklist: `docs/phase8d11_implementation.md`.
**Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients.
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
- 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`.
- Legacy removal **user-signed-off 2026-09-08** (unchanged certificate fingerprint, preexisting users usable, full-mix evidence): `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`.
- Ordinary HTTPS idle cleanup: `src/web_httpd_idle.{c,h}`, owner sweep in `web_httpd_adapter.{c,h}`, lifecycle/TLS composition in `web_server.c`; `tests/web_httpd_idle/run.py`. Independent of diagnostics/optional transports: 15-second observed idle, one-second timer/one queued probe, six rows, actual WS/async/pending exemptions, safe current-owner shutdown and stop/restart fencing. No LRU/socket/timeout/stack increase. SDK queue/owner-delay and regression contract: `docs/https_idle_cleanup.md`.
- Independent throughput diagnostics: `web_serial_transport.{c,h}` owns two fixed per-slot binary-TX aggregates and epoch fences; `web_console.c` exposes default-disabled `web performance enable|disable|show|clear`. Queue-entry/callback-entry, synchronous-send and completion/drain-return estimates, not peer receipt or scheduler-only latency. `tests/web_serial_performance/run.py`; resource/evidence limits and UART0 paired capture: `docs/web_throughput_diagnostics.md`.
- Storage compatibility: `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: `docs/legacy_credential_removal.md`.
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
- Phase 9C web login: non-consuming early quota/epoch probe before body receive, authoritative reservation after parse; raw JSON wiped before KDF, credentials before error send. Existing verification-count/window/service-restart semantics retained. `tests/web_cookie_auth/run.py --admission` and domain regressions.
- HTTP policy/UI: `web_cookie_auth` + `web_auth_parse` enforce bounded cookie/Origin/CSRF/admin admission; `web_login_ui.{c,h}` serves login, `web_ui.c` owns session-fenced Serial/Admin/Settings and shared quick controllers. Tests: `tests/web_cookie_auth/run.py` (domain variants), `tests/web_auth_parse/run.py`, `tests/web_login_ui/run.py`, `tests/web_ui_session/run.py`.
- Admission diagnostics: `web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`; six post-TLS records/32-event opt-in ring, no HTTPD off-owner inspection. [Contract](../web_admission_diagnostics.md).
- Identity/lifecycle: `web_server_replace_identity()` + `web_security` reserve service before identity; commit before reserved stop/start, no rollback after commit. `web_lifecycle_settings.{c,h}` owns original-login ID/ACK handoff. Tests: `tests/web_security/run.py`, `tests/web_admin_transport/server_lifecycle.py`.
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
@@ -75,41 +101,41 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
- Security constraints: Basic/cache removed; four absolute one-hour cookie sessions revalidate principal currentness. Four pre-login challenges (120 s), five credential attempts/60 s globally, no live session/challenge/ticket eviction. Origin/CSRF required for mutations; Origin/cookie/ticket before upgrade. Disconnect pauses reconnect but retains login; Sign out invalidates its session. Authored loader changes must update their hard-coded CSP hashes atomically.
- Session-store boundary: admitted HTTPS start initializes records; auth-init failure gates HTTPS. Failed start/accepted stop disables and wipes state. Tickets/slots require nonzero non-reused session IDs; session/account/global revocation invalidates store records before socket cleanup. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode.
- 8D.3 HTTP policy: `web_cookie_auth` owns public login/challenge/login POST/session/logout routes and protected-route checks; `web_auth_parse` handles bounded values/JSON. `web_httpd_adapter` alone reads private IDF 5.5.0 header scratch, rejects duplicate fields, defers 101 until transport admission and wipes consumed scratch while preserving right-aligned pending bytes. No SDK patch. `src/CMakeLists.txt` supplies private includes and compiles HTTPD warning/debug logs out. Test with `python3 tests/web_cookie_auth/run.py` and `python3 tests/web_auth_parse/run.py`.
- 8D.3 UI: `src/web_login_ui.{c,h}` serves standalone `/login`; `web_ui.c` validates session before serial connect/restore and handles logout/401 safely. Both scripts hash-bound, auth documents/app no-store. Tests: `python3 tests/web_login_ui/run.py` and `python3 tests/web_ui_session/run.py`. Live cutover host-tested/build-verified, M1 validated by user sign-off (numeric reserves open): `docs/phase8d3_implementation.md`.
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
- 8D.6 UI: `web_ui.c` adds admin-only Serial/Admin selection and explicit admin open/close through existing endpoints. Serial socket/client/lease survives mode switches; hidden output drains into independent 5,000-line/64 KiB-pending terminals with visible browser-drop counts. Selected keyboard only; logout/expiry/pagehide closes both with handler cleanup. Session identity changes require a clean document before adopting the view; same-session restore retains hidden-until-validated buffers. Fit readiness retries are bounded to three and cache only success. Focused `tests/web_ui_session/run.py` has 17 groups plus toolbar-order/CSP checks. 8D.6 is user-validated; telemetry, evidence limits and 8D.7 handoff are in `docs/phase8d6_implementation.md`. Numeric reserves remain open; no 8D.7 restriction change.
- **8D.88D.10 target sign-off (2026-09-08):** User reports thorough implemented Serial/account settings tests, supplies settled boot/full-mix telemetry and signs implemented work off. Covers both 8D.10 slices and 8D.9 UX. Supersedes target-pending/exclusion status in historical summaries below; exact scope/evidence/counters/limits: `docs/phase8d10_implementation.md`. No unreported checklist passes, reserve approval or M3 completion. Next 8D.11 only on separate request; no source change from sign-off.
- 8D.8: `web_ui.c` adds admin-only Settings/Serial without socket/lease changes. `web_server.c` exposes optional admin-only bodyless `GET /api/settings/serial`, eight working serial values, 256-byte response, no writes/NVS. `serial_service_get_snapshot()` is a zero-wait consistent config/running copy. `web_httpd_register_optional_get()` stages both new-route allocations before table publication (installed IDF public registration leaves a dangling descriptor on name-allocation failure); only Settings uses this startup/exact-GET adapter. 17 URI slots, six sockets/no LRU, no new task. Tests: cookie auth `--settings` (5 groups), UI (21 groups), lifecycle (12 groups). Implemented/build-verified, target/signoff pending; exact accounting and inherited registration-audit followup: `docs/phase8d8_implementation.md`. M2 remains signed off; no 8D.9.
- 8D.9: `web_serial_settings.{c,h}` owns strict 256-byte typed mutation admission and one session-bound pending/result slot. Existing `admin_ssh_console` dispatcher consumes only an ID, revalidates currentness/dequeue deadline and calls canonical serial APIs. `web_server.c` adds optional GET/POST `/api/settings/serial-operation` (19 handlers total); `web_cookie_auth_require_json()` retains Origin/CSRF/admin policy, private optional registration supports exact GET/POST. UI adds explicit framing/lifecycle/persistence with automatic completion checks (1 s, at most 10 GETs/15 s overall), refresh on known terminal results and manual uncertainty recovery without socket/lease changes. Settings stay visible/stale while pending; only Reset confirms NVS overwrite; selecting the current view is a no-op. `/api/status` uses a consistent zero-wait serial snapshot (`running:null` when unavailable). Tests: cookie `--serial-settings` (10 groups), `--settings` (6), UI (35 after UX refinement), console boundary and lifecycle (13). Build verified, target/signoff pending; bounds and failure contracts: `docs/phase8d9_implementation.md`. Supersedes 8D.8's no-8D.9 status above.
- 8D.10 first slice: `web_account_settings.{c,h}` owns compact admin account list and one session-bound other-account role/delete operation slot. `user_database_get_accounts()` is a zero-wait key/secret-free projection; `*_current()` role/delete wrappers compare target ID/auth generation under the canonical mutation lock. Existing dispatcher routes IDs; successful calls target-revoke web/SSH. Optional GET `/api/settings/accounts`, GET/POST `/api/settings/account-operation` raise handlers to 22, sockets/tasks/stacks/queue depth unchanged. UI Accounts subview preserves terminal/lease semantics, confirms mutations and auto-checks/refreshes with manual uncertainty recovery. Tests: cookie `--accounts` (5), canonical accounts, dispatcher, lifecycle (14), UI (41 + CSP). Target pending; create/password/generated-secret/self changes remain next slice, 8D.10 incomplete. Record: `docs/phase8d10_implementation.md`.
- **Current 8D.10 slice 2 (supersedes first-slice exclusions above):** `web_account_settings.{c,h}` adds create/password/self and separate bodyless POST `/api/settings/accounts/generate-password`; `user_database_set_password_current()` shares mutation-lock target checks and canonical commit logic, `user_database_generate_password_value()` generates without mutation. 768-byte/four-receive admission, 96-byte secret-free results; one-second periodic timer cancels/wipes queued non-executing credentials after 30 seconds plus scheduling latency, while dispatcher wipes executing locals on return. Generation has no retained retrieval; UI uses 60-second context-bound acknowledgement before separate submission. Self revocation can deny result retrieval; 401/disconnect is uncertain. Browser-shell restrictions unchanged. Missing generated-route registration found in review is fixed: independent optional endpoint, 23 handlers, failure isolation/restart coverage. Implementation complete, host-tested/build-verified; target/signoff pending. Parent PASS canonical accounts/boundary, parser 294, cookie accounts 9/shared and serial-settings 10, transport 25/tickets 12, store/serial and diff check; UI agent PASS 57/CSP, route agent lifecycle 15. Parent build 25.61 s, 95,908 B RAM / 1,694,237 B flash (+80/+9,880 vs slice 1; +200/+25,400 vs final 8D.9 UX). Timer runtime costs/stack margins remain unmeasured; no 8D.11. Exact evidence attribution: `docs/phase8d10_implementation.md`.
### Browser admin backend (8D.5)
- **8D.7 current status (2026-09-07): implemented scope validated; M2 explicitly signed off by the user ("Jupp, sign M2 off").** Supersedes M2-open/target-pending/continuation statements in the historical slices below; accepted M2 does not require revalidation. User verified certificate rotation and web start/stop via UART0/SSH admin/web admin, restarting after browser stop via another route; full mix without broker drops up to 230400 baud after external adapter correction is user-reported. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves/stack margins remain unapproved; no full parity or individual unreported checklist passes. Next: separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. Evidence: `docs/phase8d7_implementation.md`.
- 8D.7 third account slice: `admin_ssh_console` shares parsed browser other-account policy with `user_console`; interactive add/password and forced delete/role now allowed, self/generated/key/bootstrap/recovery still blocked. Post-prompt/pre-DB-API currentness is operation admission, not cancellation of admitted derivation/commit. Existing target-only notifications follow success. Review has no actionable findings; `python3 tests/admin_console_boundary/accounts.py` adds deterministic handler/database failure and stale-next-operation regressions. Target/M2 pending; see `docs/phase8d7_implementation.md`.
- 8D.7 second slice: exact parsed browser `web certificate rotate --force`; `admin_ssh_console.{c,h}` supplies the typed request union/owner `dispatcher_actions` mask, bounded drain/200 ms handoff to the existing 12 KiB dispatcher, persistent pending gate and revalidated executing-slot reservation. `web_console.c` schedules; `web_admin_transport.c` revalidates then calls transactional `web_security_rotate_certificate()``web_server_stop()``web_server_start()`, short-circuiting errors and retaining ownership on failed stop. SSH/UART0 unchanged. No tasks/depth/routes/assets/stacks added; target stack margins unknown. Boundary `run.py` includes `certificate.c`; lifecycle/policy and transport 25/tickets 12 host groups pass as reported. Credential/account then other owner slices remain; user authorized stacking, not target/M2 sign-off. See `docs/phase8d7_implementation.md`.
- 8D.7 first-slice history: browser `reboot`/`web stop` defer via `admin_ssh_console` control task; WEB owner revalidates cookie/principal/token before lifecycle calls. Pending console input is discarded (incoming-frame disposition latched before receive). `web_console.c` defers stop only for browser origin; other restrictions remain. Tests additionally include `python3 tests/admin_console_boundary/lifecycle.py`; handoff: `docs/phase8d7_implementation.md`. No 8D.7/M2 acceptance yet.
### Browser admin backend
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. No UI entry or broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled, 16 URI handlers.
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 39 URI handlers.
- Currentness/policy: `admin_ssh_console_open_available()` shares two slots with runtime SSH; transport-qualified tokens and owner adapters revalidate outside console locks before commands/prompts. Parsed browser policy remains narrower than typed Settings; [shell contract](../web_administration.md#browser-shell-policy). Tests: `tests/admin_console_boundary/{run,accounts,lifecycle}.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/run.py --tickets`, `tests/web_cookie_auth/run.py --admin`.
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
- Tests: `python3 tests/web_admin_transport/run.py --tickets`, `python3 tests/web_admin_transport/server_lifecycle.py`, `python3 tests/web_cookie_auth/run.py --admin`; manual smoke client/procedure in `tests/web_admin_transport/README.md` and `docs/phase8d5_implementation.md`. Final shutdown fix is host-tested and build-verified by the parent's sequential final `pio run`; target validation remains pending.
## Typed settings source and regression map
HTTPD reads zero-wait projections and queues only IDs to the existing dispatcher. One original-login slot per domain; canonical owners compare/reserve at execution. [API/lifetime and failure contracts](../web_administration.md#typed-settings-api-and-operation-lifetime).
| Domain | API / canonical owner | Focused source tests |
|---|---|---|
| Serial | `web_serial_settings.{c,h}` / `serial_service` | `tests/web_cookie_auth/run.py --serial-settings`, `tests/admin_console_boundary/run.py` |
| Accounts / keys | `web_account_settings.{c,h}` / `user_database` | `tests/web_cookie_auth/run.py --accounts`, `tests/admin_console_boundary/accounts.py` |
| Network | `web_network_settings.{c,h}` / `wifi_manager`, `mdns_service` | `tests/web_cookie_auth/run.py --network`, `tests/web_network_settings/run.py` |
| Display | `web_display_settings.{c,h}` / `local_status_ui` | `tests/web_cookie_auth/run.py --display` |
| Broker | `web_broker_settings.{c,h}` / `session_broker` | `tests/web_cookie_auth/run.py --broker`, `tests/session_broker_diagnostics/run.py` |
| SSH | `web_ssh_settings.{c,h}` / `ssh_transport`, `ssh_security` | `tests/web_cookie_auth/run.py --ssh`, `tests/ssh_management/run.py`, `tests/ssh_management/security.py` |
| HTTPS / reboot | `web_lifecycle_settings.{c,h}` / `web_server`, `web_security` | `tests/web_cookie_auth/run.py --lifecycle`, `tests/web_admin_transport/server_lifecycle.py` |
Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtures / `layout.py`. These are navigation pointers, not claims of test execution.
## SSH
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
- Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
- Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters
- Files: `src/ssh_transport.{h,c}`, `src/ssh_auth_policy.{h,c}`, `src/ssh_memory.{h,c}`, `src/ssh_protocol_policy.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
- Phase 9C: global wolfSSL hooks securely retire unpoisoned IDF5.5 usable extents (no header; shrink retains capacity, grow can need both blocks). Five checked static-lifetime algorithm setters before context publication. Tests: `tests/ssh_memory/run.py` (optional `--idf-path` extent audit), `tests/ssh_protocol_policy/run.py` (generated feature/KEXINIT/context failure checks).
- Phase 9B admission: three owner-only boot-lifetime token buckets (handshakes, password/signed-key requests, unsigned probes); no restart/counter-clear reset. Explicit keyboard rejection, pending-result marker and version/feature guard preserve reviewed callback order. Consumed admin RX/accepted TX and retired slots are wiped. Tests: `tests/ssh_auth_policy/run.py`, `tests/ssh_auth_transport/run.py`, `tests/wolfssh_auth_contract/run.py` (requires installed vendor source and production compile database). [Policy/counters/limits](../security_hardening.md#9b-ssh-admission-and-credential-handling).
- Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters; `ssh_transport_get_management_snapshot()` / `ssh_transport_manage_current()` fence lifecycle and exact session admission. `ssh_transport_replace_identity()` reserves service before task-bound security identity across stop/commit/restart, retains context until all slots retire and rejects orphan starts. Tests: `tests/ssh_management/run.py`, `tests/ssh_management/security.py`, `tests/ssh_management/runtime.py`.
- Called by: startup, network clients, user revocation, console/local UI
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; current boot start gate also depends on `web_security` readiness
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; boot start gate requires Wi-Fi and SSH security/runtime readiness, independently of HTTPS identity readiness (verified in `main.c` after accepted legacy cleanup).
- Flow: role `user` -> broker; role `admin` -> `admin_ssh_console`
- Ownership: after caller-side library initialization, one task pinned to core 1 owns runtime wolfSSH contexts/sessions; two fixed generation-tagged slots.
- Security constraint: an interactive shell request is required; exec and subsystems are rejected, and no project file-transfer or forwarding route exists. PTY is not explicitly required.
@@ -128,17 +154,17 @@ This is a semantic map, not a complete file inventory. Start here, then read the
## Administration console infrastructure
**Responsibility:** provide one canonical command registry and serialized execution for UART0 and admin SSH.
**Responsibility:** provide one canonical command registry and serialized execution for UART0, admin SSH and browser admin.
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}`, `src/network_console.{h,c}` and all `*_console.{h,c}` modules
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, command registration functions
- Called by: startup, UART0 frontend, role-`admin` SSH transport
- Called by: startup, UART0 frontend, role-`admin` SSH transport, browser admin transport
- Dependencies: ESP-IDF console/linenoise, all command handlers, user-principal currentness
- Flow: `UART0/admin SSH -> bounded request queue -> one dispatcher -> esp_console_run()`
- Flow: `UART0/admin SSH/browser admin -> bounded request queue -> one dispatcher -> esp_console_run()`
- Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls.
- Lifecycle: remote session tokens include slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
- Hidden input: UART0 and shared remote prompts reject overflow/unsupported bytes on submit with wiped output, sticky across edits; visible editing is unchanged. Tests: `tests/hidden_input/run.py` plus console boundary regressions.
- Constraint: one slow command or prompt serializes all administration. Admin SSH is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
- 8D.4/8D.5 boundary: `admin_ssh_console_open_owned()` retains explicit-index admission; runtime SSH and browser owners use `admin_ssh_console_open_available()` for the same two slots. Copied transport-qualified identity and immutable firmware-lifetime currentness/drain/lifecycle adapters; SSH publishes its allocated console index separately from its physical SSH slot. Owners handle liveness/output; dispatcher and prompt waits additionally require owner currentness (250 ms polling plus check/scheduling latency). SSH publishes locked principal copies; consumed console output is wiped. Completion scratch is nonblockingly serialized. Browser unsupported lifecycle/account mutations are rejected before execution. Focused host command: `python3 tests/admin_console_boundary/run.py`.
## Wi-Fi
@@ -146,7 +172,6 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
- Called by: startup, console, local UI, ESP event callbacks
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only.
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots.
@@ -156,12 +181,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** own OLED I2C/framebuffer operations and present status plus constrained button actions.
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; versioned NVS settings
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; generation-checked settings projection/update and explicit persistence reservation; versioned NVS settings
- Called by: startup, local UI task, diagnostics, display console
- Dependencies: copied snapshots/public APIs from serial, broker, USB, Wi-Fi, web, SSH
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task.
- Lifecycle: the low-priority task is firmware-lifetime only if button GPIO initialization succeeds; it still runs with an absent panel so a press can reprobe after successful I2C bus setup. Failed bus creation is not recoverable by that reprobe, and `display` configuration commands depend on the UI task.
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets.
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets. All configuration writers honor the UI owner's zero-wait reservation; NVS runs outside timing critical sections. Reset commits defaults before RAM publication, including CLI; buttons/diagnostic holds update activity, not configuration generation.
## Hardware and diagnostics
+98 -183
View File
@@ -1,211 +1,126 @@
# Current project state
This file is working memory. Update it during active work and before handoff; do not treat it as a permanent design record.
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
## Phase 9D continuation — IDF 5.5.3 integration — 2026-09-18
- Initial working tree already contained platform6.13/framework3.50503/toolchains14.2.0+20251107 migration, exact version guards/hash rebases and new httpd_ws override; preserved/audited rather than overwrote. Complete official packages archived/verified under .pio/idf-candidate-5.5.3; all WiFi/PHY/coex/supplicant trees match pinned package. PMF/WPA3 unchanged. Vendor radio correction integrated in candidate, not hardware exploit/closure proof.
- Existing eightC+header corrections all retained after semantic audit; new ninth C override corrects five WS signed-receive/unsigned-size comparisons introduced upstream. Dedicated982guard/canary cases plus fivecompile+fivebehavior mutations PASS. HTTPD private layout/owner and heap extent contracts re-audited5.5.3; no weakening guards. Root dependencies.lock updated only IDF5.5.0→5.5.3 to match actual resolver result; six managed dependencies unchanged.
- Default root pio run timed out200s during sharedpackage installation before compilation; no normalrootbuildPASS claim/retry. Separate fresh app-validated stage uses complete verified archives and isolatedcore. Agent buildPASS50.51s **95,552 B linked RAM / 1,749,493 B flash** (+1,212RAM/19,408flash vs old). Staging fresh-only explicitdestination avoids prior app overwrite; manifest includes transformedconfig hashes and records sole resolver lock change. All ninegeneratedC+PUBLICheader actualregistration verified; WS vendororiginal absent.
- Final parent tools/validate_phase9.py with --build-dir .pio/idf-candidate-5.5.3/app-validated/.pio/build/esp32-s3-devkitc-1-n16r8 --idf-path .pio/idf-candidate-5.5.3/core/packages/framework-espidf --platformio-core-dir .pio/idf-candidate-5.5.3/core --interop --web-performance **24/24PASS**. Pre/post3237sourceinputs equal hash3a1af78c15cfdd02da1055a8957b4086f9018862b7aa1c7c52fd2401a1a0a031. Earlier5webcookie failures were stale5.5.0 fixture updated5.5.3; allpassnow. Parent helper5/runner16fixturesPASS. Independent recheck noactionableblocker, reran WS/performance/runner, verified archive/linkpaths/sourceequality/rootlock. No device validation.
- Notice catalog semanticallyrebased9entries, other66unchanged;75inputs36testsPASS twoactualbundles77files4433930bytes. Evidence/docs updated with candidatenotrootbuild distinction and historical baselines. No full environmentclosure or legalclearance claim.
- **Next:** normal package installation/build completion remains unverified after timeout; ask user before retrying longer. Isolated source/currentconfig technically validated; wholePhase9 radio/resource/recovery/noEXTclient/abuse/operations targetgates stillpending, release source/notice/legal obligations open. Candidate5.5.3 is newest available official PlatformIO5.5 package verified, not latest upstream maintenance or alladvisoriesclosed. No upload/erase/assets/secureboot/encryptedNVS changes or commits.
## Phase 9D continuation — broader snapshot / reproducible validation — 2026-09-16
- Initial Git clean. `docs/dependency_advisory_coverage.md` completes finite dated search of all22official IDF advisories/3pages plus specified managed/browser channels. **New current-profile blocker:** PMF-capable mixed WPA2/WPA3 SoftAP matches vendor management-frame memory-corruption path; installed7S3WiFi archives match original5.5. No exploit demonstrated. `docs/wifi_security_update_plan.md` verifies fix/release bundle differences and both callback uint8_t*→size_t* ABI changes. Verified reference IDF5.5.3 commit2c211b236707889e8400c4dc5644dd5c4ee071e0 contains fix; not deployment approval or automatic recommendation over newer releases. Do not C-only patch/swap one archive/disable PMF as guessed mitigation. Next coherent framework/toolchain/source/header/radio update evaluation with audited override rebase; PHY/coexistence/privateABI compatibility unresolved. Direct SDK AES-DMA/PSRAM applicability remains explicit review gap.
- `tools/validate_phase9.py` fixed23command host plan, optional --build/--interop, strict existing build inputs, timeouts/process cleanup, no installs/device commands/permanent log captures. New10fixture tests. Actual run revealed stale admin_console_boundary extraction ending at removed ssh_malloc; replaced with reviewed consume_external_close endpoint and eight unique ordered markers, all existing assertions retained.
- **Final parent default validation23/23PASS and orchestrator fixtures10PASS.** Default intentionally skips build/interop; no firmware rebuild or new device/interoperability claim this slice. Prior firmware94340RAM1768901flash remains historical. Additional boundary accounts/lifecycle/psram suites passed agent. Run full opt-ins only deliberately with prerequisites.
- Exact retained USB/WiFi SVGs verified byte-identical official@mdi/svg7.4.47 commit9e04201d4557e729822fb57f62a316c3dea1d4a8; archive source/license/metadata/tree hashes in third_party/material-design-icons/upstream-7.4.47 and docs/icon_provenance.md. USB Google/WiFi Simran; fullApache text present. Mockup WiFi differs and firmware bitmap manual derivation not mechanically proven. Notice catalog75inputs preserving oldinputs except editedscopeREADME pins,36testsPASS; two actual agent bundles77files4433930bytes deterministic. No assets regenerated.
- Globaldocs/code map reflect fresh blocker, finite review boundaries and final parent results. Remaining release/source/radiolegal/wolf packaging/bitmap preferred-source delivery questions not cleared. Whole-phase hardware still deferred; secureboot/encryptedNVS excluded. No upgrades/device operations/commits. Continue concrete WiFi correction evaluation, not repeat completed finite snapshots.
## Development state
## Phase 9D continuation — deferred applicability / notice tooling — 2026-09-16
- **Legacy-credential cleanup signed off (2026-09-08):** User explicitly accepts cleanup, confirms unchanged HTTPS certificate fingerprint and continued use of preexisting test users, supplementing the clean full-mix telemetry below. Supersedes prior target-pending/fingerprint-unconfirmed status for this cleanup; idle-cleanup scoped acceptance stands. User suspects two boot auth failures were stale pre-flash sessions in two browser tabs; plausible, not traced/confirmed. Exact acceptance and limits: `docs/legacy_credential_removal.md`. No unreported blank provisioning/recovery/fault-injection/soak/all-key checks, numeric reserve approval or broader phase/M3 acceptance inferred. Documentation only; no source/config/build/test/device/assets/commit action.
- Initial Git clean. `tools/security_overrides.py` now requires exact ssh-userauth service, exact bounded CHANNEL_FAILURE recipient with existing fatal policy, and length-first exact names for all nine channel requests. Supported bodies and unknown-request ACK/consumption retained. No ordering/crypto/version changes. `docs/ssh_parser_remaining_review.md` closes finite PR899/902/918/919 and generic current signature caller trace: client key-skip functions remain unpatched but blocked by current server ordering; forwarding absent; no current short-digest/OID trigger found. Not exhaustive parser certification.
- `docs/idf_security_review.md` adds six named advisory dispositions with original/generated/config evidence: ECDH callers sized safely, driver-only PK path excluded, no untrusted CA promotion, privileged-local ECC sidechannel excluded not fixed, server tickets off despite compiled support, fixed CN avoids ASN zero-length replacement. No additional current-path source fix established; generic library defects remain explicitly recorded.
- `tools/release_notices.py` + `third_party/release-notices/inputs.json` assemble62 exact hash-pinned allowlisted notice inputs offline into fresh explicit output, descriptor no-follow traversal, bounded preflight, exclusive creates/no overwrite, deterministic names/bytes. No secret scanning/full repo/config/build collection. Tests30PASS; agent+independent reviewer verified two actual deterministic bundles and hashes. Historical64files541147bytes not general future promise. `docs/release_packaging.md` separates notices from source/recipient/legal obligations. No distribution clearance: radio source exception question, wolf package discrepancy, exact icon provenance and recipient delivery remain open.
- Parent `pio run` PASS **94,340 B linked RAM / 1,768,901 B flash** (+200flash). Parent seven suites PASS: parser3,258x2 plus channel production2,737x2 and fouralternate profiles, 11parser+18name+2appgate mutations; release notices30; auth135; protocol; strict crypto; SDK actual8C+header registration; ordering8,028/7mutations and12OpenSSH sessions each256KiB exact with rekey/cleancloseEOF. Independent review no actionable scoped defects, reran parser/notices and actual bundle determinism. Host evidence not hardware/resource/signoff.
- README/roadmap/hardening/wolf/license/code map integrated latest evidence and finite closure. **Next:** use hardeningdoc remaining implementation/maintenance, release/source/delivery and whole-phase target gates rather than repeatedly reopening completed finite reviews. Broader advisory coverage/unpatched unused primitives not certified; release packaging does not solve legal/source/provenance questions. User whole-phase validation remains deferred; secureboot/encryptedNVS excluded. No device/asset regeneration/dependency upgrade/commit.
- **Post-legacy-removal target evidence (2026-09-08):** User provides 60-second boot/full-mix telemetry and reports substantial traffic. Five accounts/two admins; two SSH public-key sessions and two browser password logins operational. USB writer16, SSH observer9, web observers10/11, both admin consoles active at 230400 8N1 RTS/CTS DTR active. Serial WS connect2/disconnect0, admin1/0, all supplied web send/queue/protocol/close and SSH handshake/auth/IO failure counters zero; tickets all consumed/no expiry. Two web auth failures already present at boot unchanged under load, zero invalid login credentials; request origin unknown. Boot internal/DMA/PSRAM free66,488/58,732/8,246,148 B; loaded31,820/24,064/8,112,076 B, minima15,740/7,984/8,074,196 B, largest20,480/20,480/7,995,392 B. SSH stack minimum-free18,476 boot/16,284 loaded B. Exact counters/evidence limits in `docs/legacy_credential_removal.md`. No certificate fingerprint comparison, blank provisioning/recovery, all-key verification, exact revision/duration or explicit sign-off supplied; browser RX0 and no broker drop counters prevent all-route/bidirectional/lossless claims. No source/build/device action; documents only.
## Phase 9D continuation — restricted SSH ordering correction — 2026-09-16
- **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`.
- Initial Git status clean. Implemented restricted-profile CVE-2025-14942 correction without upgrading managed pins. `tools/wolfssh_order/` archives exact PR793/819/840/855/921 bytes, hashes/commits, authoritative consolidated delta and scope. Generator now replaces eight C sources plus one internal header. PUBLIC forced overlay and stale-header marker preserve ABI across all consumers; joined `-include/path` avoids PlatformIO argument sorting/dedup failure.
- Independent SELF/PEER keying bits, exact expected-message tracking, wrong-role/preauth/repeated-message rejection, nonblocking NEWKEYS continuation and EOF/window/exit send fences. Only current Curve25519/P256 KEX supported. Existing auth/password/parser/crypto fixes retained. **EXT_INFO deliberately disabled** in advertise/send/receive paths to eliminate skipped continuation: no server-sig-algs on wire. Tested Ed25519/P256/password OpenSSH combinations work; no RSA or broad-client compatibility claim. This is not wholesale upstream backport or unrestricted CVE closure.
- Review caught misplaced EOF guard (on exit-status); corrected real SendChannelEof and retained explicit CHANNEL_REQUEST exit guard. Added actual shutdown/pending-buffer/stale-WANT_WRITE tests, zero output/state mutation during keying. Independent recheck found no remaining scoped blockers. Initial build flag integration failure and obsolete constant warning fixed; no guards weakened.
- Parent `pio run` PASS **94,340 B linked RAM / 1,768,701 B flash** (248 B vs prior slice). Final parent six suites PASS: ordering `--interop` (8,028 checks, seven rejected mutations, 12 sessions each exact256KiB with completed rekey/channel close/transportEOF), SDK actual build registration, auth135, protocol, strict crypto and parser3,124x2. Agent also passed36 consecutive interop sessions. Real generated wolfSSH/wolfCrypt tested with both KEX and three rekey directions; production authentication/broker integration and target timing still separate gates.
- Initial parent interop exposed harness early exit/false-early PASS before peer channel close; corrected harness independently owns server over AF_UNIX fd-passing, waits channel closure/EOF and requires both exit0 plus exact binary data. Final parent AF_UNIX test needed explicit unsandboxed approval; no remote network or device operation. Remaining suites sandboxed.
- Updated review/hardening/roadmap/license/code map with effective restricted scope and evidence. **Next:** finish remaining advisory/parser applicability and release notice/source obligations, then whole-Phase9 target validation including cleanup during rekey, no-EXT client compatibility and validation CPU/heap/stack costs. Secure boot/encrypted NVS excluded; no device operations/assets/version upgrades/commits. Phase9 remains in progress, not production sign-off.
- **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.
## Phase 9D continuation — SSH parser / key validation — 2026-09-15
- **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.
- Initial Git status clean; previous 9D work already retained. Current slice keeps managed pins and seven-source override mechanism unchanged. `tools/security_overrides.py` now bounds IGNORE/service/helper parsing, rejects window-add overflow, fixes ECC/Ed25519 label predicates, and enforces ECC nested r/s plus outer signature exact consumption and Ed25519 exact signature-field consumption. Password wipe/async and state ordering unchanged.
- `cmake/wolf_crypto_policy.*` PUBLIC-propagates `WOLFSSL_VALIDATE_ECC_IMPORT` and `WOLFSSL_ECDHX_SHARED_NOT_ZERO`, with fail-closed resolved guards. Verified prior P256 peer point reaches scalar multiplication without equivalent validation; nontrivial low-order X25519 inputs bypassed old precheck. Real vendor tests now reject these inputs. Evidence in `docs/ssh_key_validation_review.md`; generic digest/OID API hardening remains separate, no current short-digest trigger found in inspected callers.
- Parent `pio run` PASS **94,340 B linked RAM / 1,768,949 B flash** (+1,732 flash vs prior9D). Strict crypto suite initially rejected concurrent parser changes; corrected independent exact-delta expectations, not weakened provenance. Final parent five suites PASS: `wolf_crypto_policy`, `wolfssh_parser_contract` (3,124 cases in each of two stack modes, six guard-removal mutations), `wolfssh_auth_contract`135cases, `ssh_protocol_policy`, `sdk_security_overrides --build-dir .pio/build/esp32-s3-devkitc-1-n16r8`. Independent review no scoped blockers; strict crypto/parser suites rerun PASS. Real vendor arithmetic tests and parser doubles remain separate, not live SSH transactions.
- **Ordering CVE-2025-14942 still unresolved.** Official registry queries returned404 for wolfSSH1.5.0/wolfSSL5.9.2; upstream releases exist, so a full upgrade requires deliberate pinned component integration. Temporary-only PR793/819/840/855/921 backport evaluation found manual context adaptation plus unresolved SendNewKeys WANT_WRITE / skipped SendExtInfo continuation and extInfoSent rekey semantics. No partial ordering/header-layout patch installed. Next choose coherent tested nonblocking source+header backport or upstream component integration; preserve all local auth/wiping/parser contracts. Full provenance/rekey/negative-order tests required. Details and official links in wolf review.
- Other remaining work: deferred parser/API applicability, release notices/source obligations, and whole-phase hardware validation. Review recommends a valid-but-inconsistent private/public ECC fixture and parser-to-real-crypto integration gate. No hardware/latency/resource/handshake claims, no device operations/assets/upgrades/secure-boot/encrypted-NVS changes. Do not mark Phase9 complete or require intermediate hardware signoff.
- **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.
## Phase 9D — advisory mitigation / operational review — 2026-09-15
- **8D.11 partial target evidence (2026-09-08):** User supplied 60-second fresh-boot/full-mix telemetry; no functional/sign-off claim. Full mix eventually established (two SSH public-key sessions, SSH writer + USB/two web observers, both admins), but last browser admission again required retries. Three TLS errors `-0x0050` verified against installed mbedTLS as NET_CONN_RESET, not allocation failure; cause unknown. Loaded internal/DMA free 32,596/24,840 B, lifetime minima 5,468/532 B, largest 22,528 B; per-region minima are conservative/non-simultaneous. Settled free is similar to prior 8D.10, minima much lower; no causal regression attribution. SSH stack minimum-free 18,468 boot/16,276 loaded B. Web send failure/close 1, expired tickets 3; SSH handshake/auth failures zero; no broker drop counters. Exact memory/counters/evidence limits in `docs/phase8d11_implementation.md`. Previously accepted admission issue recurred, not fixed; investigate timing/socket occupancy/allocation correlation before capacity changes. Key algorithms/new UI provenance, mutation checklist, HTTPD/dispatcher margins, cleanup/soak and target sign-off remain pending. Documentation-only update; no source/build/device action.
- Work in progress; user validates Phase 9 as a whole. Secure boot/encrypted NVS excluded. No device operations or dependency upgrades.
- New `security_operations.md`, `dependency_licenses.md`, `wolf_security_review.md`, and `idf_security_review.md` record runbooks, bounded license inventory, and advisory applicability. Phase 9 is not release-ready: wolfSSH message ordering/parser issues and distribution/source-notice questions remain unresolved.
- Current implementation adds consistent Xtensa small X25519/Ed25519 policy and fail-closed compiler guards; seven pinned overrides now include DHCP option bounds, TLS EMS error propagation, and certificate-name allocation failure handling. All generated copies carry dated project modification notices. Nested mbedTLS targets are selected explicitly; installed dependencies remain untouched.
- Parent `pio run` PASS: **94,340 B linked RAM / 1,767,217 B flash** (same RAM, 64,092 B flash vs 9C; not runtime headroom). Parent SDK override suite with actual seven-source registration, strict production wolf crypto policy/vector suite, wolfSSH auth contract135cases, and SSH protocol/context suite all PASS with `CCACHE_DISABLE=1`. Independent review found no actionable implementation defects and repeated the first two suites successfully. DHCP includes196,623 guard-page/equivalence cases; extracted EMS/X509 error tests do not establish full handshake/persistence fault behavior.
- Review docs now distinguish original baseline from implemented mitigations. Seven generated modification/date notices resolved; broader license packaging/source questions remain. No target timing/interoperability, fault-injection or legal-clearance claim. **Next:** coherent wolfSSH message-order/parser correction or reviewed upgrade (1.5.0 candidate, not verified managed compatibility), ECC validation review, remaining advisory inventory and release notices. Do not call Phase9 complete or wait for per-slice target approval.
- **8D.11 implemented (2026-09-08), host-tested/build-verified; target sign-off pending:** Accounts now supports fingerprint listing and bounded Ed25519/P256 public-key import/delete/clear. Conditional database wrappers check target identity under the canonical lock; existing dispatcher/account slot and target-only revocation remain. Optional POST `/api/settings/accounts/keys` brings handler budget to 24, six sockets/tasks/stacks/queue depth unchanged. Sparse stable key slots are supported; review finding fixed and regressed. Parent canonical accounts, cookie accounts, UI, lifecycle (16), build and diff checks passed; final agent UI extension passes 68 groups plus CSP. Final production build 14.85 s, 96,076 B RAM / 1,703,685 B flash (+168/+9,448 vs 8D.10). Exact API, evidence attribution and pending target checklist: `docs/phase8d11_implementation.md`. No device/assets/commit action, reserve approval, full M3 claim or 8D.12 work. Supersedes the older wait-for-8D.11 instruction below, not accepted 8D.88D.10/M2.
## Phase 9C — library cleanup / protocol policy — 2026-09-15
- **8D.88D.10 implemented scope signed off by user (2026-09-08):** User provides settled boot/full-mix telemetry and reports thorough Serial parameter display/settings and user/account testing: “Implemented work can be signed off.” Accepts 8D.8, 8D.9 including UX refinement, and both implemented 8D.10 slices, superseding pending-signoff/target-blocker statements below without inventing individual checklist passes. Current source/handoff confirms credential/self workflows and generated route complete. Exact flashed revision/browser/durations/reboot/fault-injection details not separately supplied. Full mix at **230400 8N1 RTS/CTS, DTR active**: SSH writer8, USB observer9, web observers26/11, SSH admin and web admin, two cookie sessions; no broker drop counters supplied. Boot internal/DMA/PSRAM free **69,928/62,172/8,246,368 B**, loaded final **32,556/24,800/8,087,656 B**, loaded minima **19,228/11,472/8,065,444 B**, largest **23,552/23,552/7,995,392 B**. SSH minimum-free stack **18,476 boot / 16,284 loaded B**. One SSH handshake failure/broker revocation, one WebSocket send failure/close and one invalid login retained without diagnosis. All six loaded samples, counters, scope and evidence limits: `docs/phase8d10_implementation.md`. Numeric reserves, HTTPD/dispatcher stack margins, cleanup/soak evidence and earlier accepted admission issue remain followups, not reopening sign-off. Browser-shell restrictions/UART0 recovery and M2 stand; no full M3 claim. **Wait for separately requested 8D.11.** This update documentation-only, no new build/test/device/source/config/asset/commit action; preserve existing user `platformio.ini` edit.
- User requested continuation; hardware still deferred to **whole Phase 9**, no per-slice approval gate. Initial Git status clean. Secure boot/encrypted NVS excluded; no eFuse/partition/dependency-version/asset changes, no SDK/managed source mutation.
- `tools/security_overrides.py` + `cmake/security_overrides.cmake` included after root `project()`: require exact IDF5.5/version/originalSHA/edit matches, generate four full notice-preserving source copies in build tree, replace exact component source preserving flags/includes. Compile inputs are pinned originals PLUS checked-in edits. Missing/changed/ambiguous target/source fails; reconfigure tracks originals/script/generated. Never edit derived files or blindly repin. Original sources remain unchanged; compiled dependency behavior intentionally changes.
- Overrides: HTTPS post-handshake allocation-failure TLS deletion, complete failed-start destruction and raw key pre-free wipe (failedstop owns live state); HTTPD scratch allocate/copy/wipe/free preserve old on failure, finalwipe, null first-read/nullable parserpointer fix; server-local TLS1.2 ECDHEECDSA AES128/256GCM, no renegotiation, no change clientdefaults/global primitives; wolfSSH GetSize both password lengths, failed newpassword framing skips callback, checked method suffix wiped before responses with prefix/canaries preserved, library asyncpending retains payload (project synchronous).
- `ssh_memory.{c,h}` installed as globalwolfSSL hooks before initialization, PSRAMpreferred/internalfallback, usableextent securefree/noheaders, shrink wipes tail retains capacity, growth old+new allocation failure preservesold. Guards unpoisonedIDF5.5; dynamicIDF TLS buffers compile-rejected for cleanup contract. These costs need actual peak/latency evidence; liveinline/compaction/stack/hardware intermediates not allwiped.
- `ssh_protocol_policy.{c,h}` applies checked staticlists before contextpublication: Curve25519/P256KEX, P256hostkey, AES128/256GCM, hmacsha256 advertisement, Ed25519/P256userkey advertisement (DB enforcesauth). LegacyCBC/CTR/removedKEX-only clients fail; no identitymigration. TLSpolicy is serveronly so future outboundHTTPS RSAclients unaffected.
- `web_cookie_auth`: nonconsuming earlyquota/epoch probe before receive, final postparse reservation unchanged; JSONwipe beforeKDF, credentialsafter/beforeerrors; handler-lifetime RetryAfter. Existing5verification/60s fixedwindow/restartreset/malformednotcharged retained; no challengefairness/generalrequestlimit claim. All10domain modes passed implementation.
- Final parent `pio run` PASS **94,340 B linked RAM / 1,831,309 B flash** (sameRAM/+1,384flash vs9B). Final fivefocused suites PASS incl installedSDK allocationextent, SDKcleanup/TLS/generator/nullfirstread+actual4source registration, SSHpolicy actual15contextintegration+KEXINIT, generatedwolfSSH135cases, web early admission. 17 related regressioncommands PASS before finalnullablefirstreadpatch; patchedSDKsuite+firmware rerunafter. Two independent reviews no blocking issues; inherited null-pointer subtraction found/fixed/tested. Standard UBSan linking unavailable earlier; new parser/allocator trap instrumentation passed in focused development. No realnetwork/hardware/cryptohandshake or reserveclaims.
- Bounded review in `docs/security_library_review.md`: normal inspected mbedTLS record/MPI/PK/HMAC and wolfECC scalar paths alreadywipe; newhooks cover observed retired DER/buffer gaps, not proof everycopy erased. Password1264ASCII/PBKDF2SHA25650k/generated24of64 unchanged pending costmeasurement; P256selfsigned20252049/trust verification retained; CSP/headers reviewed no blindHSTS or crypto-global removal.
- **Next: Phase9D current upstream advisory/license review and provisioning/rotation/reset/backup/recovery/decommissioning runbooks.** No external advisory/CVE review or full license audit performed by9C; do not describe pinned versions/localfixes as certified current. Any versionupgrade now must re-audit/source-rebase overrides. Target checklist in hardeningdoc adds modern/legacy suite negotiation, rekey, malformed encryptedpassword packets, TLS/scratch failurecleanup, securefree CPU and old+new allocationheadroom under fullmix. Do not wait for9Ctarget signoff tocontinue.
The following implementation entries predate target sign-off and retain historical build/test attribution; their target-pending status is superseded above.
## Phase 9B — SSH admission / credential handling — 2026-09-15
- **Current 8D.10 slice 2 complete, host-tested/build-verified (2026-09-08); target/signoff pending, not target accepted:** Create/password/generated-value/self workflows use the existing dispatcher slot, 768-byte/four-receive admission and canonical mutation-lock identity checks. One-second periodic timer cancels/wipes non-executing credentials at 30 seconds plus scheduling latency; admitted work is not cancelled and locals wipe after return. Generation is separate before commit, with no retained retrieval; UI 60-second lifetime/context-bound acknowledgement and best-effort secret clearing. Immediate self revocation can lose POST/results: 401/disconnect is uncertain, inspect after relogin before retry. Browser-shell restrictions unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage: **23 handlers**, six sockets/no LRU unchanged. Parent PASS canonical `accounts.py`, boundary `run.py`, parser **294**, cookie `--accounts` **9 plus shared**, `--serial-settings` **10**, transport **25**/tickets **12**, store `--serial` and diff check. UI agent **57 plus CSP** (four added beyond 53); route agent lifecycle **15** pass. These UI/lifecycle results are agent-attributed, not claims of the parent's additional reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. New timer runtime costs, heap reserves and stack margins remain unmeasured/unapproved. Record/checklist: `docs/phase8d10_implementation.md`; slice 1 below remains historical. This update is documentation-only; no source/test/build action by this documentation agent. No sanitizer validation, device/assets/commit/8D.11 action, full 8D.10 target signoff or prior-phase signoff/reserve approval inferred.
- User requested continued Phase 9 work and will validate **the phase as a whole**. Do not pause between slices for target approval; all target gates remain unrun and collected in `docs/security_hardening.md`. Secure boot/encrypted NVS remain excluded; Phase 8 sign-off stays closed. Initial Git status for this slice was clean.
- `ssh_auth_policy.{c,h}`: 72-byte owner-only boot-lifetime state, independent handshake and password/signed-key buckets (capacity6, refill1/10s), unsigned-probe bucket(capacity12, refill1/5s). No waits, allocations, per-peer maps or NVS writes. Reconnect, stop/start/rotation/counter clear do not replenish; no refunds, idle saturation/no excess credit, clock regression fails closed. Global starvation is a deliberate documented tradeoff; natural refill only after hostile traffic subsides, not fairness/zeroCPU protection.
- `ssh_transport` gates handshake before wolfSSH allocation and credentials before database/ordinary signature work; keeps existing per-slot three-counted-attempt closure and 15s deadline. Explicit pending-result marker fences duplicate/unexpected completion. wolfSSH1.4.20 and certificates/none-disabled guard; keyboard prompt rejection callback/context prevents unregistered callback dispatch while keeping password/publickey advertisement. New aggregate admission/probe/throttle/limit/backend/method counters via `ssh counters`; `add_counter` saturates (do not generalize to unrelated direct lifecycle increments).
- Admin RX consumed spans / TX positively accepted spans are securely wiped; pending retry and serial hot-path bytes unchanged. Whole retired slot securely wiped before generation/fd restoration. `console_input` and shared remote hidden prompts reject overflow/unsupported bytes on submit, sticky across editing; visible CLI behavior preserved; existing callers prevent prefix persistence. Input errors wipe output and return zero length.
- Source audit verified actual pinned wolfSSH auth callback order and `SendChannelData` positive copied/consumed behavior. Tests pin `internal.c` SHA256 and execute extracted vendor parser/send functions with crypto/IO doubles plus actual compiler-feature preprocessing. Followup resolved reviewer concern about keyboard error-path one-byte write: inline buffer initialized, framed packets retain padding reserve, exact-sized protocol-identification pending state cannot reach auth, rejection purges without advancing length. This is a narrow invariant audit, not library security certification.
- Validation: parent `pio run` PASS **94,340 B linked RAM / 1,829,925 B flash** (+144RAM/+1,360flash vs9A; not runtime reserve). Parent and independent review PASS all four new suites `ssh_auth_policy`, `ssh_auth_transport`, `wolfssh_auth_contract` (35 vendor cases+resolved feature profile), `hidden_input`; token policy UB-sanitizer trap mode passed, standard UBSan runtime absent. Related 11 command suite PASS: SSH management/security/runtime, console boundary/accounts/lifecycle, admin SSH policy, web cookie SSH/accounts, web admin transport+tickets, security build policy18. `git diff --check` PASS. No blocking review findings; no upload/erase/device operations/deps/generated assets/commit.
- 9B's planned library/protocol and early web-admission follow-up is implemented and bounded by 9C above. Challenge fairness/full-memory wiping are not guaranteed. External maintenance/lifecycle work is next; no intermediate target sign-off needed.
- Final target checklist includes real SSH clients offering several keys, bad signatures/passwords, keyboard decline, each pool/refill/restart-clear persistence, established mixed transport/USB/UART0 responsiveness and reserve measurements, hidden-input errors/CRLF timing and crash recovery. UART0 paired/delayed LF relies on next-prompt flush; host UART fake does not model timing. No real-crypto/live packet-network or target claims from extracted-function tests.
The following slice 1 entry is explicit historical evidence; its unavailable/next-slice statements and build/counts do not describe current slice 2.
## Phase 9A — crash/debug baseline — 2026-09-15
- **8D.10 first slice complete (2026-09-08), host-tested/build-verified; target pending, phase incomplete:** User requested 8D.10; selected plan's pre-edit list/role/delete versus create/password split. `web_account_settings.{c,h}` provides admin-only compact list and other-account role/delete on one session-bound slot, executed by the existing dispatcher. Zero-wait `user_database_get_accounts()` and mutation-lock identity checks preserve canonical final-admin/NVS semantics and reject stale/recreated targets; successful calls target-revoke web/SSH. Three optional routes, 22 handler budget, six sockets/no LRU, unchanged tasks/stacks/queue depth. Accounts subview has confirmations, bounded auto-completion/refresh (10 GETs/15 s), uncertainty recovery and navigation/session fencing without broker lease effects. Final parent cookie `--accounts` (5 groups/shared auth), canonical accounts, console boundary, server lifecycle (14), UI (41 + CSP), transport/tickets and store/serial pass; prior settings/serial/admin/policy/lifecycle reruns also pass. Final `pio run` **25.00 s / 95,828 B RAM / 1,684,357 B flash**, **+120 / +15,520 B** vs final 8D.9 UX; diff check PASS. Exact evidence/contracts/target checklist: `docs/phase8d10_implementation.md`. No independent review/sanitizer/device/assets/commit action or reserve/prior-phase signoff. Next is second 8D.10 slice: create/password, one-time generated secrets and safe own-account changes; these remain unavailable. No 8D.11 work or full 8D.10 completion.
- User requested Phase 9 and explicitly excluded secure boot and encrypted NVS. Roadmap now marks Phase 9 in progress; Phase 8 acceptance remains closed. Physical extraction/firmware replacement stay outside the threat model even after hardening; no encryption/eFuse/partition changes.
- `src/security_build_policy.c` enforces resolved no-core-dump + silent-panic-reboot settings and rejects panic/register output, runtime/panic GDB stubs and OCD-aware handling. Registered unconditionally in CMake; explicit defaults. No runtime task/buffer added. This intentionally removes decoded panic traces, not ordinary boot/status logging or physical debug access.
- `docs/security_hardening.md` defines shared operational profiles, secret-bearing artifact handling, evidence limits and target gates. Reserved partitions unchanged; old dump/credential copies are not erased. No generated assets/dependency changes.
- Validation: `pio run` PASS **94,196 B linked RAM / 1,828,565 B flash**. Host policy matrix PASS17; actual generated SDK header PASS as eighteenth case. Initial host test hit read-only ccache storage; `CCACHE_DISABLE=1` rerun passed. Independent review found no actionable issues and repeated both host modes and diff check. No upload, erase, hardware validation or commit.
- **9A target gate remains open:** synthetic-secret controlled panic, no register/UART/flash dump, reboot rather than halt, UART0/USB/network recovery and broker behavior. No test-only panic command was added to production.
- 9A changed no authentication behavior; its planned SSH follow-up is now implemented in 9B above. Whole-phase target validation remains deferred.
- **8D.9 UX refinement (2026-09-08), host-tested/build-verified; target pending:** User reported successful Apply required awkward manual Check Result then Refresh and approved automatic flow/removing routine popups. `web_ui.c` now keeps settings visible/stale while pending, checks acknowledged operations every 1 s up to 10 GET attempts/15 s overall, then refreshes working values while preserving outcome. Errors/exhaustion/lost ack use manual recovery; no POST retry, no resume after navigation. Only Reset retains a saved-NVS overwrite confirmation; inline discard semantics remain. Current-view selection is a no-op so repeated Settings clicks cannot cancel work. Parent UI **35 groups + renderer/CSP**, `pio run` **10.91 s / 95,708 B RAM / 1,668,837 B flash**, diff check PASS; **0 / +2,112 B** vs original 8D.9. No backend/assets/device/commit action or new signoff. Details/evidence limits in `docs/phase8d9_implementation.md`; target automatic-completion, failure/manual-recovery and full-mix latency checks pending.
## Web popup cosmetics — 2026-09-14
- **8D.9 continuation complete (2026-09-07), implemented / reviewed / host-tested / build-verified; target/signoff pending:** Preserved inherited implementation; typed Apply/Start/Stop/Save/Load/Defaults/Reset uses one session-bound slot and the existing dispatcher queue, 256-byte JSON POST and 96-byte explicit GET results. No new tasks/stacks/queue depth or broker lease semantics; 19 URI handlers, six sockets/no LRU. Fixed persistent uncertain-result warnings and consistent zero-wait `/api/status` serial state (`running:null` when unavailable). Final parent Serial 10, Settings/status 6, UI 27/CSP, console boundary and lifecycle 13 groups pass; additional transport/tickets/store/admin/account/lifecycle regressions passed during review. Final `pio run` 23.73 s, **95,708 B RAM / 1,666,725 B flash**, **+128 / +12,196 B** vs 8D.8. Deadline is dequeue admission only; blocked dispatcher retains pending slot, admitted NVS work may finish after logout, completed results can be replaced. Exact contracts/tests/limits and pending target checklist: `docs/phase8d9_implementation.md`. No target/device/asset/commit action, reserve approval or 8D.8/8D.9 signoff. Stop before separately requested 8D.10; M2 acceptance and existing deferred restrictions/admission followups stand.
- `src/web_ui.c`: contextual buttons now occupy entire status cards; bounded grid columns and clipped/ellipsized values prevent narrow-window overflow. Quick panels preserve the visible terminal title, admin toolbar and selected-view indication; full-page promotion retains drafts and restores Settings presentation.
- Quick panels hide full-page explanations and duplicate detail readouts while preserving live status, errors and operation results. Wi-Fi exposes configured profile selection/enabled state and AP policy, plus Apply/Save/Refresh/Result; empty profile slots and advanced editors remain full-page-only. Profile selection is an editing target, not a direct-connect command; existing backend operations are unchanged.
- Validation: all 161 UI behavior groups and C/HTML checks PASS; 36 Chromium geometry fixtures PASS at 320/600/900/1200px. `pio run` PASS (94,212 B linked RAM / 1,830,329 B flash). No generated-asset regeneration, upload, erase or hardware validation. Updated regression coverage in `tests/web_ui_session/`.
- **8D.9 original handoff (2026-09-07; superseded by completion above):** User explicitly requests end-to-end Serial typed edits/persistence despite pending 8D.8 target validation. This is continuation authorization, not 8D.8 target signoff. Plan: verify canonical serial and concurrency contracts; implement a single bounded session-bound operation slot on the existing dispatcher with nonblocking HTTP admission/result reads; add explicit Serial controls and focused regressions; finite build, diff review and accurate resource/target handoff. No later domains/popovers/generated assets/device/commit actions. Existing modification to `docs/phase8d8_implementation.md` belongs to parent/user and is preserved. M2 acceptance, deferred restrictions, unresolved admission and unapproved memory/stack followups stand.
## Focused cleanup / PSRAM review — 2026-09-13
- **8D.8 separately authorized, implemented / host-tested / build-verified (2026-09-07), target/signoff pending:** Admin-only Settings/Serial in the existing document, bodyless server-authorized `GET /api/settings/serial`, eight typed working values with 256-byte response bound. Nonblocking serial-state snapshot avoids blocking HTTPD on console reconfiguration; no mutations/NVS/broker/terminal lifecycle side effects. Selection preserves both sockets/lease and hidden output; explicit refresh, safe errors, cancellation/identity/expiry and concurrent-reconnect fencing. One optional URI (17 total), six sockets/no LRU unchanged. Private IDF adapter stages new-route descriptor/name before publication to avoid installed public registration's dangling entry on strdup failure; only the new Settings route uses it, broader inherited registration audit remains a followup. Final `pio run` **24.31 s, 95,580 B RAM / 1,654,529 B flash**, **0 / +5,952 B** vs recorded 8D.7; **+1,048 / +54,556 B** vs 8D.0. Target snapshot/descriptor 36/24 B; requested route/table heap +49 B before overhead, handler local frame 416 B excluding callees, no stack/task/queue/capacity increase. UI **21**, Settings **5**, lifecycle **12**, transport **25**/tickets **12** and auth/store/login/parser/console/policy regressions pass; diff check passes. No device, sanitizer, generated-asset or commit action; inline loader/CSP hashes unchanged and verified. Task/todowrite tools unavailable; implementer review fixes rerun, no independent-agent review claim. See `docs/phase8d8_implementation.md` for exact tests/resources/limits/pending target checklist. **M2 signoff, deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups stand. Stop before separately requested 8D.9; no new-phase signoff inferred.** This supersedes the older next-8D.8/wait-for-request instructions below, not their signoff/evidence.
- User-authorized post-acceptance code review: removed superseded `admin_ssh_console_open()` SSH-only wrapper; production already uses available-slot admission. Updated adapter regression to actual production entry. Wrapper was already linker-discarded: no binary saving attributed to removal.
- Moved ping queue payload4,200 B and public user snapshot2,156 B to lazy PSRAM-only lifetime allocations, no fallback. Queue control/internal locks unchanged; allocation failure affects only ping or status/list/show, not UART0 registration/mutations/recovery. Snapshot fully wiped on success/error. No serial hot-path, stacks, CPU, queue bounds or external-BSS config changes.
- Baseline pio PASS23.79s100,556 RAM/1,828,573 flash. Final parent pio PASS22.11s **94,212 RAM/1,828,809 flash: 6,344 B linked internal RAM/+236 B flash**. Requested lazy PSRAM6,356 B plus allocator overhead; target pointer sizes4 B, controls84 B verified. This is not measured runtime-minimum improvement. CPU160 retained.
- Focused ping allocation/callback/end-capacity and accounts allocation/full-wipe/failure/retry tests PASS; console boundary/lifecycle/policy and SSH management/runtime suites PASS. Independent review found no actionable bugs and reran ping/accounts/boundary/diff PASS. Sanitizer linking unavailable (missing host runtimes), no sanitizer or hardware pass. No upload/erase/commit. Next target check: ping/user list/show repeatedly alongside NVS writes and full mix230400, collect serial/broker counters and memory before/after first allocations.
- Further audited opportunities, not implemented: OLED framebuffer1,024 B (internal I2C staging retained), local/remote completion scratch1,024 B each, optional web diagnostic ring2,816 B, remote console output payload8,192 B. Console rings need explicit secret-output wiping/lifecycle and admission-failure isolation; do not relocate mixed state wholesale. Leave authoritative user database, driver/DMA buffers, locks and task stacks internal. Global external-BSS enable is not surgical: it also changes SDK library placement.
- **M2 explicitly signed off by the user (2026-09-07):** After 8D.7 implemented-scope validation and discussion of read-only settings next, the user says "Jupp, sign M2 off". This supersedes all earlier M2-open statements and continuation instructions below; accepted M2 does not require revalidation or imply full browser command parity. Browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed or diagnosed. Numeric memory reserves and stack margins remain unapproved follow-ups, not blockers reopening M2. **Next: 8D.8 read-only settings entry and Serial page, only when separately requested; this sign-off alone authorizes no implementation.** Evidence/history: `docs/phase8d7_implementation.md`. Documentation only; no source/tests/build/device/commit action.
## Accepted state — 2026-09-13
Earlier development entries below are historical; the latest M2 sign-off supersedes their pending status and next-work instructions, not their evidence.
- **8D.22 explicitly signed off by the user:** “Yep, I tested the firmware thats a 8d.22 signoff.” The retained Phase 8D scope is complete; earlier per-slice pending target/review/integration gates are superseded. Roadmap already records 8A/B/C as complete and target-hardware validated, so **Phase 8 is complete**. Acceptance does not manufacture individual unreported test passes.
- [Roadmap](../roadmap.md#phase-8--role-based-users-and-administrative-access--complete) holds the completed-phase gist; [web administration](../web_administration.md) holds current API/owner/recovery contracts; [acceptance evidence](../web_administration_acceptance.md) holds the latest report and limits. The old plan/baseline/per-slice histories are consolidated, not archived as another timeline. Test READMEs now link directly to current contracts and evidence; obsolete forwarding notes were removed too.
- Final prior production build **PASS: 100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. No build/test/device execution is implied by this documentation update. The prior combined binary WebSocket-send fix was separately user-accepted at **160 MHz / 230400 baud with full mix including browser admin**; preserve combined send and bounded failed-send isolation.
- Latest loaded capture: two serial WS, USB, two SSH roles with SSH serial writer; browser admin used then closed, not active in the capture. Internal/DMA/PSRAM free **31,508 / 23,752 / 8,136,624 B**, minima **2,052 / 460 / 8,065,972 B**, largest **18,432 / 18,432 / 7,995,392 B**; SSH minimum-free stack **15,028 B**. Full boot/loaded table is in acceptance evidence. Web send/queue/protocol and SSH IO errors zero; one SSH handshake failure/session revocation. Missing latest broker/serial counters prohibit an exact zero-drop inference.
## Follow-ups, not acceptance blockers
- **8D.7 validated by explicit user sign-off (2026-09-07), implemented scope only:** User explicitly says "Ok, mark 8D.7 as validated." Supersedes historical target-pending/acceptance-blocking and continuation instructions below for the implemented stop/reboot, certificate and other-account slices. User reports thorough testing, verified certificate rotation and web start/stop with lifecycle via UART0/SSH admin/web admin (restart after browser stop via another route), and full mix without broker drops up to **230400 baud** after correcting external adapter baud. Intermittent supported two serial + one admin WebSocket admission failures have recently not recurred and are accepted nonblocking, not fixed or diagnosed. Preserve investigation/telemetry below. No separately reported reboot-specific or individual mutation/injection results; do not invent checklist passes. Self/generated/key/legacy-credential and other owner parity remain deferred and restricted; bootstrap/recovery remain UART0-only. Numeric reserves/stack margins and **M2 acceptance remain open**; no full-parity claim. See `docs/phase8d7_implementation.md`. Documentation-only sign-off; no new implementation authorized. Wait for a separate request.
- Extremely low internal/DMA lifetime minima warrant correlated transient-headroom investigation; overlapping capability pools and conservative/non-simultaneous region minima do not prove allocation failure. Numeric reserves, HTTPD/dispatcher stack margins, peak correlation and detailed soak/fault evidence remain unapproved/unreported. Do not reopen functional sign-off or invent a reserve threshold.
- SDK TLS `-0x004C` is generic NET_RECV_FAILED, not OOM. Two boot auth failures plausibly involve stale cookies, but causation is unconfirmed. Prior intermittent web admission issues and accepted idle cleanup do not justify claiming every admission failure fixed.
- Real DNS/reannouncement, NVS power-loss, browser geometry/accessibility, individual fault cases and exact duration claims require explicit evidence if investigated; retained [regression procedures](../user_administration_tests.md) are not execution records.
- The earlier documentation-only handoff is superseded by the user's Phase 9 request; current scope and evidence are recorded above. Device operations remain unperformed.
Earlier development entries below are historical; the latest sign-off supersedes their pending status and next-work instructions, not their evidence.
## Scope and safety to retain
- **8D.7 target admission investigation (2026-09-07):** User reports 60-second clean boot, then intermittent browser-admin timeouts with SSH writer/admin, USB and two web observers; removing one observer allows admin, final observer eventually reconnects. Boot internal/DMA/PSRAM free 70,860/63,104/8,246,176 B; loaded free 33,428/25,672/8,084,100 B, minima 5,280/344/8,058,464 B, largest 23,552/23,552/7,995,392 B. Boot SSH stack minimum-free 18,472 B. Exact revision/browser arrangement/loaded duration unknown. TLS -0x0050 is reset, -0x7280 EOF, not allocation errors (installed SDK verified). Leading unproven hypothesis: six shared HTTPD slots occupied by WebSockets plus retained ordinary HTTP connections; full IDF server stops accepting, recv timeout does not expire idle slots. Synchronous handshakes and memory/global socket pressure may contribute; TLS already allocates externally. Need failed-stage browser timings, count-only HTTPD occupancy/allocation-failure correlation and paused-traffic comparison. No runtime change/build/device operation; record in `docs/phase8d7_implementation.md`. Full-mix reliability and 8D.7/M2 acceptance remain open; do not attribute to account slice or claim OOM without evidence.
- 8D.15 dedicated typed network diagnostics was removed; shell diagnostics remain subject to frontend policy. Unimplemented 8D.19 ordinary browser-session/native-USB controls were removed; SSH settings remain. No implicit full browser-shell parity or browser identity reset/recovery/export.
- One UART1 broker writer, isolated observers and binary transparency. UART0 is administrative recovery; native USB is network-independent UART1, not administration or uninterrupted reboot.
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; later revocation/timeout does not cancel admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity never rolls back on lifecycle failure. Lost ACK/result means uncertainty, never automatic replay.
- Preserve private IDF HTTPD version guards, at-most-one owner-work reservations through failed destruction, retained SSH context until all slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
- **8D.7 third account slice review completed (2026-09-07), target pending:** Inherited uncommitted code/tests enable browser other-account interactive add/password and forced delete/role; self/generated/key/bootstrap/recovery remain blocked. Independent production/test reviews found no actionable findings; prior precommit-liveness finding withdrawn. Contract is post-prompt, pre-database-API operation-admission currentness: admitted work may commit and target-revoke after disconnect/expiry, subsequent stale work rejects. Documentation now records uncertain-result recovery and deterministic-test limits. Boundary/accounts/policy (review agent), lifecycle, transport 25/tickets 12 and store/serial (parent) pass. Parent incremental `pio run` PASS 3.35 s, **95,580 B RAM / 1,648,577 B flash**, 0/+516 B versus certificate slice. No production edits in continuation, device operation, sanitizer pass, asset regeneration or commit. See `docs/phase8d7_implementation.md`. Supersedes older all-account-blocked/next-account notes; remaining owner/credential parity, target checks, numeric reserves and 8D.7/M2 acceptance remain pending. No next slice started.
## Previous documentation consolidation handoff
- **8D.7 second certificate slice implemented / host-tested / build-verified, target pending:** Exact parsed browser `web certificate rotate --force` is enabled. Typed deferred-request union preserves queue capacity; immutable owner `dispatcher_actions` transfers certificate work after bounded drain/200 ms delay to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending-input gate survives handoff; token/principal/session revalidation and executing-slot reservation fence stale work/self-detach reuse. WEB uses transactional certificate commit → stop → start, returning on early errors and retaining ownership on failed stop. SSH/UART0 unchanged; other account/network/credential/SSH mutations remain blocked. No new tasks/depth/routes/assets/stacks. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Parent final rerun passes boundary (`run.py` including `certificate.c`), lifecycle, policy, transport **25**/tickets **12**, server **11**, cookie/admin, store/serial, UI **17** plus renderer/CSP and diff checks; independent reviewer has no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. Owner mask/local scratch and target stack margins remain unmeasured; host sizeof is not target proof. Drain/acknowledgement is bounded, not execution latency or peer receipt. **User explicitly authorized continuing to stack the next bounded slice, not target sign-off. Next: credential/account, then other owner slices; 8D.7/M2 acceptance and numeric reserves pending.** See `docs/phase8d7_implementation.md` for separate slice histories and certificate force/trust/relogin/USB/SSH/failure checklist. Supersedes the first-slice next-step/continuation-pending instruction below. Production/tests/docs updated and parent build/regressions executed; no device operation, asset regeneration or commit.
Initial Git status was clean. This task changes root `README.md`, `docs/` and five test-directory READMEs; executable source/tests/config/generated assets remain untouched. Independent documentation review checked acceptance scope, owner contracts and local links. It restored explicit pointer-backed HTTPD response-header lifetime and same-version SDK-patch audit warnings, updated test README links, and removed obsolete forwarding notes without reopening sign-off.
- **8D.7 first increment implemented / host-tested / build-verified (2026-09-06), target pending:** User requests starting 8D.7. Scope split enables only browser `reboot` and `web stop` through existing bounded deferred control; WEB revalidates session/principal/token before lifecycle APIs. Adds browser-origin query and pending snapshot flag; HTTPD discards deferred input, latching disposition before receive. Review fixed second-frame-before-poll and cancellation-during-receive races; final reviewer confirms correction. Canonical handler/console/policy, transport **23**/tickets **12**, lifecycle **11**, cookie/admin, store/serial and UI **17** suites pass. Final `pio run` **12.44 s, 95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** vs 8D.6. No tasks/routes/capacity/assets/UI/credential changes or device operations. `docs/phase8d7_implementation.md` holds behavior, restrictions, validation limits/resources and disruptive target checklist. **8D.7/M2 incomplete; next slice remains safe HTTPS identity/certificate handling**, then remaining owner/account restrictions. Obtain target regression or explicit user decision before stacking runtime changes. `web stop` intentionally closes both browser routes; restart via UART0/admin SSH. Prior 8D.6 sign-off stands; internal/DMA minima **6,516 / 1,580 B** and numeric reserves remain follow-ups.
- **8D.6 validated by explicit user sign-off (2026-09-06):** After 60-second boot/full-mix/partial-cleanup telemetry, user confirms all discussed remaining checks and requests wrap-up. Closes 8D.6 including Open admin and toolbar-order fixes, superseding older target-pending notes below. Evidence in `docs/phase8d6_implementation.md`: full mix at **230400 baud**, browser admin + admin SSH, two web serial/SSH/USB broker clients; web24 sole writer. Loaded internal/DMA/PSRAM free **33,900 / 26,144 / 8,087,468 B**, minima **6,516 / 1,580 / 8,063,428 B**, largest **24,576 / 24,576 / 7,995,392 B**; SSH stack minimum-free **16,280 B**. Partial cleanup retains serial, internal/DMA largest returns to **31,744 B**. One SSH handshake failure and rejected input byte remain unattributed. Exact revision/browser/counts/soak duration not separately supplied; no invented detailed execution or reserve approval. Numeric reserves and low lifetime minima remain follow-ups, not blockers reopening signed-off 8D.6. Last toolbar build/tests: **7.60 s, 95,580 B RAM / 1,646,489 B flash**, 17 UI groups/CSP/diff pass. This handoff changes documentation only. **Wait for separately requested 8D.7 — bounded web-shell lifecycle parity and M2 acceptance.** Preserve current restrictions until their safe owner paths are implemented; no settings/M2 completion or new implementation is implied.
- **8D.6 Open admin bug fix (2026-09-06), target retest pending:** Confirmed frontend/backend ticket mismatch: `openAdmin()` required 32 URL-safe characters (serial format), while admin issuance returns 64 lowercase hex characters. Valid POST responses therefore threw before WebSocket construction with the user's exact generic message. Fixed only the admin validator; split the previously shared serial/admin test fixture, assert exact admin query URL and reject malformed/serial-format tickets without closing serial. Correct-format fixture reproduced pre-fix failure; final production-renderer/CSP + 17 UI groups, cookie/admin endpoint integration, transport 19/tickets 12 and lifecycle 11 pass. These are separate frontend and backend harnesses, not real-browser/on-wire integration. CSP loader hash matches the reported `o6St...` policy; rejected UUID/index.js scripts' origin is unverified, not grounds to relax CSP. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged sizes. Diff check passes. No assets, upload, commit, backend/admission changes or target pass. Short bug record in `docs/phase8d6_implementation.md`; parent independent review and user target retest pending.
- **8D.6 final re-review complete (2026-09-06):** Independent reviewer confirmed session-identity isolation and bounded fit-readiness fixes and their regressions, with no remaining actionable findings in the reviewed changes. Final build/test results below stand; no further source change or build for this documentation update. Browser/target validation and numeric reserves remain pending, not phase sign-off or M2 completion. Stop before 8D.7 until separately requested after the validation decision.
- **8D.6 independent review fixes implemented / host-tested / build-verified (2026-09-06), target pending:** Fixed both P2 findings without unrelated scope. Before session adoption, compare retained username/role/session-stable CSRF; mismatch hides old terminals, closes both routes/fences work and replaces the document at `/`, including live admin and logout paths. Pagehide keeps buffers hidden until same-session validation; unchanged identities retain scrollback. Fit caches bounds only after valid measurement/success, with three bounded readiness retries, teardown cancellation and generation fencing. Expanded UI harness passes **16 groups**; login eight groups, cookie/admin integration, parser 268 and store/serial reruns pass. Final finite `pio run` after all fixes: **21.35 s, 95,580 B RAM / 1,646,489 B flash**; review delta **0 / +1,376 B**, versus 8D.5 **0 / +9,216 B**, versus 8D.0 **+1,048 / +46,516 B**. `git diff --check` passes. See `docs/phase8d6_implementation.md` for exact regression cases and unchanged pending target checklist. Parent reviews fixes only; no target pass, reserve approval, sign-off or M2 completion. Existing worktree edits preserved; no server/protocol/assets/8D.7/upload/commit action. Supersedes initial 8D.6 counts/build below.
- **8D.6 implemented / host-tested / build-verified (2026-09-06), target pending:** Admin-only Serial/Admin selector in authored `web_ui.c`, explicit Open/Close admin using existing protected endpoints, lazy separate 5,000-line terminals with 64 KiB pending writes each and visible browser-drop counts. Switching preserves serial socket/client/lease and drains hidden output; selected input only, writer controls visible in both. Admin closure is isolated; logout/expiry/pagehide cancel both with listener cleanup and stale-generation fencing. Existing 8D.5 restrictions/server/capacities unchanged; no assets/upload/commit. 13 UI groups, eight login groups, cookie/admin integration, parser 268, store/serial, admin transport 19/tickets 12, server lifecycle 11 and console boundary suites pass. Final finite `pio run`: **22.22 s, 95,580 B RAM / 1,645,113 B flash**, **0 / +7,840 B** versus 8D.5, **+1,048 / +45,140 B** versus 8D.0. Diff check passes. See `docs/phase8d6_implementation.md` for exact contracts, test limits/resource accounting and pending desktop/mobile/prompt/full-mix/soak checklist. Parent independent review pending; no target execution, numeric reserve approval, 8D.6 sign-off or M2 completion. Prior 8D.5 validation stands. Stop before separately requested 8D.7. This supersedes older planned-selector notes below.
- **8D.5 validated by explicit user sign-off (2026-09-06):** User supplied settled cold-boot telemetry and reports successful **15-minute full-client-mix active-use soak at 230400 baud**, with a few broker drops under extremely fast/dmesg output, then explicitly closes 8D.5. Supersedes older pending/incomplete notes below. Boot internal/DMA/PSRAM free **70,876 / 63,120 / 8,246,360 B**, minima **59,560 / 51,804 / 8,242,140 B**, largest **31,744 / 31,744 / 8,126,464 B**; SSH stack minimum-free **18,472 B**. Admin initialized/attached with ESP_OK and 167/240/1,552 B static/ticket/payload accounting; no active clients at boot. Full mix/soak is user-reported, not evidenced by the idle snapshot; exact drop count/client, flashed revision and loaded/post-soak/cleanup telemetry not supplied. See `docs/phase8d5_implementation.md`. These limits do not reopen sign-off or imply zero-drop operation. Numeric reserves/runtime socket cost remain open. No new build/device/source action for sign-off. Wait for separate **8D.6** request; M2 not yet complete.
- **8D.5 backend implemented / host-tested / build-verified (2026-09-06), target pending:** Preserved interrupted tickets/transport/server/shared-console/SSH/policy/revocation work and inherited test tooling. One admin socket, two tickets, existing two shared console slots, 1,552 B PSRAM-only payload, 20 ms ESP timer/no new task, six HTTPS sockets/no LRU eviction, 16 URI handlers. Added real cookie/store/ticket/transport endpoint integration tests. Final IDF lifecycle review found queued `httpd_sess_trigger_close` retains a reusable socket-slot pointer; changed the admin path to HTTPD-owned `shutdown`, with HTTPD read cleanup and retry/reuse regressions. Transport 19 groups + tickets 12, server lifecycle 11, combined endpoints, both console suites, store/serial, cookie/parser (268), login UI (8), serial UI (9) all pass; diff check passes. **Parent reports sequential final `pio run` after the shutdown fix passed in 23.55 s at 95,580 B RAM / 1,637,273 B flash: +416/+9,224 versus prerequisite, +496/+10,100 versus 8D.4, +1,048/+37,300 versus 8D.0.** This supersedes the continuation's earlier pre-fix build; history remains in the implementation record. Parent's final independent security integration reviewer reported no actionable findings. Target ELF payload/slot/ticket sizes 1,552/80/96 B, ticket state+lock 240 B, transport static symbols 167 B before padding, ESP timer internal allocation 32 B before heap overhead. See `docs/phase8d5_implementation.md` for exact commands, inherited-versus-final fixes, resource accounting, restrictions and manual target/client checklist. Client `--help` only exercised in the continuation; this final evidence update changed documentation only, with no source/tests/build/device/network/asset/UI/upload/commit action. **Stop before 8D.6; target acceptance, runtime socket cost, numeric reserves and M2 remain open.** This supersedes the prerequisite-only pause below.
- **8D.5 prerequisite resumed / host-tested / build-verified (2026-09-06), target pending:** User requested continuation. Preserved existing uncommitted dispatcher/owner-currentness and prompt-cleanup changes in the console/SSH adapter; extended tests to production SSH snapshot/principal publication and wiping. Both console suites pass; independent production review found no actionable defects. `pio run` passes in **38.46 s**, **95,164 B RAM / 1,628,049 B flash** (**+80 / +876 B** versus recorded 8D.4); map confirms 80 B for two principal copies. No new tasks/routes/sockets/UI or backend yet. See `docs/phase8d5_implementation.md` for contract, scope split, test limits and target checklist. **8D.5/M2 incomplete.** Obtain target regression or explicit user decision before stacking live backend work. Exact next increment is still 8D.5 tickets/HTTPD-owned admin transport/protected admission/revocation/restrictions and integration checks, not 8D.6. Previous 8D.4/M1 sign-offs stand; numeric reserves remain open. This supersedes older wait-for-8D.5-request notes below.
- **8D.4 validated by explicit user sign-off (2026-09-06):** User confirms successful empty Enter and soak testing and explicitly closes Phase 8D.4. This supersedes older pending/in-progress notes below, including the 8D.3 empty-Enter target retest. Boot/full-client-mix evidence and sign-off are in `docs/phase8d4_implementation.md`. Exact soak duration/revision, post-soak/cleanup telemetry and other detailed checklist results were not supplied; these are evidence limitations, not blockers to the user-approved closure or claims of execution. Numeric reserves remain open. No new build/device operation for sign-off. Wait for a separate **8D.5** request; no browser admin backend or M2 completion is claimed.
- **8D.4 target validation in progress (2026-09-06):** User supplied clean-boot/full-client-mix UART0 evidence, recorded in `docs/phase8d4_implementation.md`. Loaded internal/DMA/PSRAM free **34,632 / 26,876 / 8,089,284 B**, minima **20,648 / 12,892 / 8,077,516 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,280 B** (boot **18,472 B**). Four broker clients at 115200 baud: SSH writer, USB and two web observers; user/admin SSH and both browser roles admitted successfully, no reported I/O/transport failures. One cumulative SSH broker revocation retained without diagnosis. Exact flashed revision/duration not supplied. Console-specific regressions, lifecycle/soak/cleanup and explicit phase sign-off remain pending; numeric reserves remain open. No 8D.5 request or implementation.
- **8D.4 implemented / host-tested / build-verified (2026-09-06), target pending:** User separately requested the small console boundary. Added transport-qualified identity and immutable output-drain/lifecycle owner callbacks in `admin_ssh_console`, retained the SSH admission API in `ssh_transport`, and kept two shared slots, one dispatcher and all capacities/stacks. Shared completion scratch now has nonblocking ownership; occupied-slot replacement and deferred input fail closed. `python3 tests/admin_console_boundary/run.py` and existing policy suite pass; independent review found no defects. `pio run` passes in **43.61 s**, **95,084 B RAM / 1,627,173 B flash** (**-424 / +1,448 B** versus recorded latest 8D.3). See `docs/phase8d4_implementation.md` for contracts, host-test limits, accounting and target checklist. No browser admin routes, uploads or 8D.5 work. Prior M1 sign-off stands; numeric reserves and 8D.4 target regression remain pending. Stop for validation/user decision before separately requested 8D.5.
- **8D.3/M1 completed by explicit user sign-off (2026-09-06):** User closes M1 after post-soak evidence, superseding all older pending/blocked/in-progress statements below. Four broker clients remain active at 115200 baud (SSH writer; USB/two web observers), both SSH routes and two cookie sessions active. Post-soak internal/DMA/PSRAM free **38,656 / 30,900 / 8,138,320 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. Minima/largest unchanged from earlier loaded sample; no reported transport failures. Record/evidence limits in `docs/phase8d3_implementation.md`. Sample is still connected, not disconnected cleanup; exact soak duration/revision not supplied. Unrecorded detailed checks do not reopen signed-off M1 or become claims of execution. Numeric reserves remain open; empty-Enter fix target retest remains unrecorded separately. **Wait for a separate 8D.4 request; no M2 implementation performed.**
- **8D.3 both-role target login confirmed / mixed-client evidence (2026-09-05):** User reports successful HTTPS user+admin login after Origin fix; previous login blocker is resolved. Settled internal/DMA/PSRAM free **71,204 / 63,448 / 8,247,744 B**. Mixed load free **33,868 / 26,112 / 8,089,060 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. 115200 baud, four broker clients (SSH sole writer, USB + two web observers), user/admin SSH active. No reported web transport or SSH I/O errors; 6 login attempts/3 invalid credentials/1 logout, zero security rejections. Two identical loaded heap samples are not a soak/leak or reserve proof. Full details/provenance in `docs/phase8d3_implementation.md`. **M1 validation in progress, not signed off.**
- **Admin SSH empty Enter fix (2026-09-05):** User's empty line was classified as UART0-restricted because `remote_command_allowed` required argc>0. Changed only helper classification to allow empty input through normal quiet IDF handling; currentness and physical-only commands remain protected. New `python3 tests/admin_ssh_policy/run.py` passes 15 cases using production helper/installed parser. Build passes **21.04 s**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash). Not uploaded or target-tested. Ask for empty Enter/normal-command smoke on next flash; no 8D.4 refactor.
- **8D.3 Origin-null fix (2026-09-05), target retest pending:** User confirmed challenge200/login403 with `Origin: null`, same-origin Fetch Metadata and pre-login cookie; post-attempt counters show 0 password attempts and 7 security rejections. Root cause is non-CORS fetch POST under no-referrer. Login fetch now uses `mode:'cors'`; app helper uses cors for POST tickets/logout, unchanged GET mode. Same-origin credentials/fixed paths/redirect denial/CSP/no-referrer and strict server Origin/CSRF remain intact. Login CSP hash updated atomically. Both UI suites and cookie-policy suite pass; build **95,508 B RAM / 1,625,705 B flash**, **14.30 s** (+16 B flash). No upload; actual Firefox Origin header/login/serial/logout retest and M1 acceptance still pending. See implementation record; supersedes the speculative diagnosis below.
- **8D.3 target login blocker (2026-09-05):** User supplied clean-boot/60-second settled telemetry, then reports both user/admin login rejected with the page's HTTP-403 message. Record in `docs/phase8d3_implementation.md`: internal free/min/largest 69,004/66,752/31,744 B; DMA 61,248/58,996/31,744 B; PSRAM 8,223,116/8,218,204/8,126,464 B; SSH stack minimum-free 18,464 B; no active sessions/broker clients, UART stopped. Boot counters precede login attempts; no post-attempt result yet. Exact flashed revision not supplied. Production login-renderer/CSP test passes. Browser warnings name other script hashes (possibly injected scripts), plus denied favicon/file URL; do not relax CSP based on these alone. Need failed endpoint/status/error code and nonsecret Origin/Sec-Fetch-Site. Investigation hypothesis: same-origin fetch mode plus no-referrer policy may serialize POST Origin as null; confirm wire headers before changing request policy. **M1 blocked, not signed off.** No corrective firmware change for this report yet.
- **8D.3 live cutover implemented / host-tested / build-verified (2026-09-05), M1 target pending:** Resumed another agent's uncommitted completed server/browser implementation; preserved it, verified installed IDF header/upgrade semantics, and fixed right-aligned pending-buffer cleanup with an actual-IDF-reader regression. Cookie login/logout is live, Basic/cache removed. Final build **95,508 B RAM / 1,625,689 B flash** (+248 / +23,764 B versus 8D.2). See `docs/phase8d3_implementation.md`. This supersedes older inert/planned statements below. **Stop for M1 target/browser sign-off before 8D.4; numeric reserve gates remain open.**
- **8D.3 inert login renderer completed (2026-09-05):** User requested continuation after the parser split. Added standalone `web_login_ui.{c,h}` plus production-renderer/Node tests; no live route or Basic-auth change. Build and focused suites pass; 8D.3/M1 remains incomplete. Remaining work is the atomic server/app cutover, then mandatory browser/target validation. See active task below.
- **8D.3 preparatory parser split (2026-09-05):** User requested continuation. Per the plan's 600800-line scope review, selected inert private request parsing before the larger atomic login/logout cutover. `src/web_auth_parse.{c,h}` and focused host tests added; no live HTTP callers or authentication changes. See active task below. 8D.3/M1 is **not complete**; Basic remains active. Prior 8D.2 user sign-off stands; numeric reserves remain open.
- **8D.2 validated by user sign-off (2026-09-05):** User explicitly closed 8D.2 after post-flash boot and full-client-mix samples. This supersedes older target-pending/in-progress statements below. Evidence and limits are in `docs/phase8d2_implementation.md`; unrecorded regression details are not claims of execution or blockers to the signed-off checkpoint. Numeric reserves remain open. Wait for a separate 8D.3 request.
- **8D.2 target samples received:** User reports post-flash clean boot and full client mix at 115200 baud, recorded in `docs/phase8d2_implementation.md`. Loaded free internal/DMA/PSRAM **39,200 / 31,444 / 8,138,284 B**, minima **18,784 / 11,028 / 8,109,712 B**, SSH stack minimum-free **16,296 B**. Four broker clients confirmed, SSH writer; no reported SSH I/O or web failures. One cumulative SSH broker revocation and an initial console usage error retained without diagnosis. Target validation is in progress, not signed off; lifecycle/revocation/soak/cleanup evidence remains pending. No 8D.3 request.
- **8D.2 implemented / host-tested / build-verified (2026-09-05), target regression pending:** User requested 8D.2 only after 8D.0/8D.1 sign-off, with numeric reserve gates still pending. Session-bound serial tickets/slots, session-specific cleanup, and account/global store invalidation are implemented; Basic remains the only public auth route. This supersedes older next-task and dormant-ticket-binding statements below. See `docs/phase8d2_implementation.md`. Stop before 8D.3.
- **8D.1 validated by user sign-off (2026-09-05):** Following boot/full-client-mix samples, the user reports a long-lasting command with output at full **115200-baud line speed and no dropped broker packets**, and explicitly closes 8D.1 validation. This supersedes older in-progress/not-target-verified statements below. Detailed duration/command/byte totals were not supplied; do not invent them or reopen the signed-off phase solely for unrecorded regression details. Numeric reserve gates remain open; cookie authentication remains dormant/planned. Stop before 8D.2 until requested. See `docs/phase8d_baseline.md` for evidence and sign-off.
- **8D.1 target validation in progress (2026-09-05):** User supplied fresh-boot (UART stopped) and full-client-mix (115200 baud, SSH writer, admin SSH, USB and two web observers) samples, recorded separately in `docs/phase8d_baseline.md`. Loaded internal/DMA/PSRAM free bytes **41,420 / 33,664 / 8,162,824**, minima **19,384 / 11,628 / 8,115,028**; SSH stack minimum-free **16,288 B**. No reported SSH I/O or web failures; one cumulative SSH broker revocation retained without diagnosis. These supersede older statements below that no target samples exist, but are not full 8D.1 sign-off. Exact changed-firmware hash, lifecycle/HTTPS restart and soak/cleanup coverage remain pending. No continuation to 8D.2 requested.
- **8D.0 sign-off (2026-09-05):** User marked the baseline validated and identified tested source as latest checked-in revision `d8999cd4a96e477fabd392ced02d810c3cd22d0f`. Current samples and authoritative sign-off are in `docs/phase8d_baseline.md`; they supersede older baseline measurements/provenance notes below. User attributes SSH I/O errors to out-of-spec **460400-baud** testing, distinct from transcript UART configuration **460800**. Numeric reserve approval and 8D.1 post-change target validation remain pending; do not reopen 8D.0 solely for unrecorded checklist details or claim those checks were executed.
Based on checked-in source plus `README.md` and `docs/roadmap.md`:
- Hardware characterization, serial service, session broker, USB CDC, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and local display/control are implemented and documented as target-hardware validated.
- Phase 8A role-based user storage/UART0 administration and Phase 8B role-aware HTTPS/SSH authentication and targeted revocation are documented as target-hardware validated.
- Phase 8C admin SSH is implemented in source, uses the shared `esp_console` registry, and has passed target-hardware validation.
- Phase 8D.08D.6 and M1 are validated by user sign-off; 8D.7 implemented scope is user-validated and M2 explicitly signed off on 2026-09-07. Browser administration retains deferred parity restrictions; numeric reserves/stack margins remain unapproved. Follow `docs/phase8d_plan.md`: next is separately requested 8D.8 read-only settings entry and Serial page, with no implementation authorized by sign-off alone. Changing terminal modes must preserve the browser serial broker client and any writer lease. The roadmap retains the full end-state requirements.
- Security/production hardening, OTA, BLE evaluation, advanced networking, and optional filesystem features remain future roadmap work.
- Reserved OTA, coredump, NVS-key, and storage partitions do not imply those runtime features are implemented.
## Recent memory audit
- Fixed failed-initialization ownership leaks for wolfSSH, partial HTTPS startup, and TinyUSB teardown. Failed teardown now retains ownership and blocks unsafe duplicate initialization.
- Serial-service RX/TX stream payloads (16 KiB and 8 KiB effective capacity) now prefer PSRAM with internal fallback; FreeRTOS controls and UART driver buffers remain internal.
- The 5,360-byte transactional user-database candidate now prefers PSRAM with internal fallback while the live database remains internal. Candidate contents are wiped after each transaction and wiped/freed on initialization or recovery failure.
- UART and admin-SSH completion formatter buffers were reduced from 2 KiB to 1 KiB each; current worst-case output is 890 bytes and overflow remains fail-closed.
- Linked RAM fell from 99,508 to 92,188 bytes (7,320 bytes). PSRAM placement of serial payloads additionally removes about 24 KiB of normal internal-heap pressure on the target.
- `pio run` passes. A preliminary target run reports significantly more free memory and stable, improved operation after these changes. This is useful evidence but not completion of Phase 8C validation.
- The reviewed mDNS-enabled build uses 94,532 bytes of linked static RAM, 2,344 bytes above the earlier 92,188-byte baseline, and 1,599,765 bytes of flash. Minimizing the managed component saved 112 bytes of linked RAM and about 5.9 KiB flash versus the first mDNS build. Its 4 KiB task stack remains internal, while checked-in settings move general mDNS allocations to PSRAM and disable unused browse, component CLI, AP/ETH, and multiple-instance features. Runtime heap impact still requires target measurement.
- Remaining targeted checks include stored/migrated/recovered user-database mutations, USB enumeration, HTTPS start/stop failure recovery where injectable, SSH initialization/login, completion display, and sustained multi-transport serial traffic while checking `memory` telemetry.
## Clearly incomplete or transitional areas
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
- Current HTTPS UI retains status/serial for both roles and exposes an admin-only selector in 8D.6. The 8D.7 policy permits bounded other-account mutations, deferred stop/reboot and certificate rotation; unsupported self/generated/key/legacy-credential and other owner-specific paths remain restricted.
- Browser authentication uses cookie login/logout without Basic fallback. M1 and 8D.48D.6 are signed off; 8D.7 implemented scope is user-validated and M2 explicitly signed off on 2026-09-07. Full parity is deferred, numeric reserves/stack margins remain unapproved, and no new implementation is authorized.
- NVS encryption, secure boot/flash encryption review, production certificate/provisioning policy, and OTA are not implemented. HTTPS login has a bounded global five-verifications/60-second throttle, not comprehensive cross-transport DoS protection.
## Known inconsistencies
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.
- 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.
## Items to verify in future work
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
- Re-audit the private HTTPD adapter on SDK changes (including same-version patches): parsed-header layout, right-aligned pending data, explicit handshake/frame installation and log suppression. Host tests do not establish real socket behavior.
## Completed Task - Phase 8D.3 Live Authentication Cutover
- **Inherited implementation:** `web_cookie_auth.{c,h}`, `web_httpd_adapter.{c,h}`, parser optional-cookie validation, server/console/transport integration, browser app/session recovery, and focused suites. No Basic authorization/cache remains. Four one-hour sessions, four 120-second pre-login challenges, five password checks per 60-second window, four non-evicting 30-second tickets/two serial sockets. Fourteen URI slots, unchanged six HTTPS sockets and application task/stack/queue capacities. No generated assets or SDK files changed.
- **HTTPD decision:** Other agent chose an isolated **private-IDF adapter**, not the previously proposed SDK patch. Verified first-only header getters, append-only pointer-backed Set-Cookie (six-header successful login), auto-101-before-handler flow and private frame installation against installed 5.5.0. Serial URI uses ordinary GET until authenticated ticket/currentness/broker admission, then explicit handshake. Exact version guard requires re-audit on update; not a source-hash guarantee. HTTPD logs above ERROR compiled out to avoid secrets/ticket queries. Durable boundary recorded in architecture/design decisions and implementation record.
- **Fix in this continuation:** Pending HTTPD bytes are right-aligned. Inherited wipe preserved the wrong end, risking pipelined HTTP/early-frame corruption. Fixed consumed-prefix wipe and tested 0128 pending lengths plus partial reads using extracted installed `httpd_recv_pending`. Kept all other inherited source work intact.
- **Validation:** `python3 tests/web_cookie_auth/run.py`, parser 268-case suite, login UI eight Node groups, serial app nine Node groups, and store `--serial` integration mode pass. Cookie suite also executes store tests and extracts installed header getter/setter/pending-reader functions; handshake/network/tasks remain doubled. Final `pio run` passed **17.62 s**, **95,508 B RAM / 1,625,689 B flash**; +248/+23,764 B versus 8D.2, +976/+25,716 B versus recorded 8D.0. Auth symbols 637 B before placement padding (including 576 B challenges); Basic cache/key removal offsets much of it. No runtime reserve or stack margin inferred.
- **Handoff (updated at sign-off):** `docs/phase8d3_implementation.md` contains policy, source verification, accounting, user-provided login/mixed-client/post-soak evidence and explicit **M1 closure on 2026-09-06**. No agent device operation or new build was performed to record sign-off. Earlier missing-check notes are evidence limitations/regression coverage, not blockers to this closure. Numeric reserves remain open. Next implementation is **8D.4 only when separately requested**; preserve prior sign-offs and the distinction between connected post-soak data and disconnected cleanup.
## Previous Task - Phase 8D.3 Inert Login Rendering
- **Scope:** Second permitted preparatory split after scope review of remaining challenge/throttle/HTTP-route/application work. Added only `src/web_login_ui.{c,h}`, CMake registration, `tests/web_login_ui/` and documentation. Standalone renderer has no live HTTP caller or URI registration; Basic cache/auth, existing app and serial protocol, capacities, task stacks and generated assets are unchanged. No upload, erase, commit or branch change.
- **Behavior when integrated:** No fetch on page load; explicit Sign in obtains challenge with `X-Login-Bootstrap: 1`, then POSTs JSON with CSRF. Same-origin credentials/mode, no-store fetch, redirect rejection, 512-byte response bound and UTF-8 field/body limits. Generic safe-text errors, bounded Retry-After display/manual backoff, no automatic credential retry, fixed success navigation to `/`. Inputs disabled while pending; password fields/references cleared best-effort, attempt aborted on every exit, 15-second deadline, pagehide/pageshow generation guards. No localStorage/cookie access or logging; JavaScript/browser memory cannot be securely wiped.
- **Policy/resources:** Standalone 7,387-byte HTML document plus terminator in its object; no protected asset dependencies or template interpolation. Five additional headers (no-store, nosniff, no-referrer, frame denial, matching SHA-256 CSP), below existing eight-header limit. Rendering itself allocates no module heap/session/task. Unused renderer leaves final linked RAM/flash **95,260 / 1,601,925 B**, unchanged versus 8D.2 and parser split. Future linked/read-only data, HTTPD send/TLS and runtime stack/heap costs remain cutover measurements; no runtime reserve approval implied.
- **Validation:** `python3 tests/web_login_ui/run.py` passes production-C NULL/header/send-failure checks, exact rendered CSP hash and eight Node VM behavior groups. `python3 tests/web_auth_parse/run.py` passes 268 cases; existing store and `--serial` host modes pass. Independent review caught/fixed unread error-body lifetime and re-entered password retention; regression tests cover abort signals/modeled streams and pending-input cleanup. Final `pio run` passed in **8.25 seconds**, unchanged sizes. `git diff --check` passed. No real browser/CSP enforcement/bfcache, HTTPD sockets, TLS or target exercise.
- **Next exact work:** Continue **8D.3 atomic live cutover**, not more login rendering or 8D.4. Reuse prepared parser/renderer/store/serial binding. Implement bounded challenge/throttle and complete cookie/session/CSRF/Origin policy, replace Basic for every app/asset/status/ticket/upgrade route together, add `/login`, challenge/login/session/logout routes, and integrate existing application's session validation/logout/401 recovery without changing explicit serial reconnect. Retain no-live-eviction ticket policy cutover, fail-closed initialization and account/session-specific revocation. Duplicate-header detection and two Set-Cookie behavior must be verified against actual IDF HTTPD, not assumed. Keep response header/route/body budgets explicit. Then M1 target/browser pause and resource evidence; no M2 work.
- **Remaining gates:** Full M1 checklist in plan/baseline applies only once live: both roles/fresh and former Basic profiles, direct IP/mDNS, challenge/session/capacity/throttle/Origin/CSRF errors, logout/account switch/expiry/reboot/revocation isolation, repeated serial cycles, UART0/USB/user+admin SSH and soak/cleanup memory. Numeric reserves remain pending; 8D.2 user sign-off stands.
## Previous Task - Phase 8D.3 Inert Request Parsing
- **Scope:** Private allocation-free helpers for canonical Host/Origin comparison, unique named 64-lowercase-hex cookie extraction, and strict login JSON. CMake registration plus `tests/web_auth_parse/`; no live route, UI, store, transport, task, socket, stack-size or generated-asset change. Split before coding because full challenge/throttle/routes/UI/tests cutover exceeds the work-unit guideline. No commit, upload or erase.
- **Contracts:** Exact bounded byte spans; canonical origin max 128 bytes after optional default-port normalization, ASCII DNS/IPv4 authorities only (IPv6 rejected), mandatory matching HTTPS Origin. Cookie header max 1024 bytes, strict unquoted values even for unrelated cookies. Login body max 512 bytes, exactly username/password strings, decoded limits 16/64 bytes; proper UTF-8/JSON escape/surrogate validation. Unknown/duplicate fields and NUL rejected. Database policy remains authoritative. Failures clear complete outputs; successful credentials and original body require caller wiping. No dynamic allocation or persistent state.
- **Validation:** Focused production-C host suite, existing store and `--serial` modes passed. Independent review found no memory-safety defect; default-port maximum-length normalization was fixed and regression cases added. Final rerun/build results recorded in the plan. Host tests are not real HTTPD/browser, sanitizer or target validation.
- **Resources:** Build reports **95,260 B linked RAM / 1,601,925 B flash**, unchanged from 8D.2. Unused helpers are not live firmware paths; their future linked flash and request-stack cost must be measured at cutover. No new runtime allocation, task, route, queue or socket. Numeric reserve gates remain pending.
- **Next exact work:** Continue 8D.3, not 8D.4: wire the complete challenge/session/login/logout/CSRF/Origin policy and browser recovery atomically, or first take the permitted inert login rendering split if the remaining change still exceeds scope. Never expose partly protected cookie routes. Integration must enforce duplicate HTTP header rejection, bounded complete body reads, content type/method/Fetch Metadata checks, and wipe request buffers on every exit. Bootstrap's permitted missing-Origin GET requires separately validated Host handling, not weakening mutation/upgrade Origin checks. Keep account validation tied to existing database capacities. Remove Basic only with the complete security cutover, then stop for mandatory M1 browser/target sign-off.
- **Target checks:** No hardware execution for this inert split. On the live cutover run the full M1 checklist in `docs/phase8d_plan.md` and `docs/phase8d_baseline.md`: both roles, usable errors/logout/account switching, expiry/reboot/revocation/isolation, direct IP/mDNS, capacity/throttle/CSRF, serial reconnect, UART0/USB/user+admin SSH and repeat-cycle/soak memory evidence. No browser-authentication success is claimed here.
## Previous Task - Phase 8D.2 Serial Session Binding
- **Checkpoint/scope:** Clean `93eef0e67641f2672c56692a7785e50f31cf236d`; only `web_serial_transport.{c,h}`, `web_session_store.{c,h}`, one Basic ticket call in `web_server.c`, focused host tests and documentation changed. No upload/erase/commit or generated assets. Existing `user_console` and legacy-sync callers already reach the extended revocation hook.
- **Implementation:** Non-reused 64-bit originating IDs in four tickets/two slots, zero explicitly Basic-only. Matching-ID upgrade and exact principal binding, expiry/currentness at sensitive boundaries and existing periodic owner checks. Session-specific invalidation/cleanup, account-name/all-session invalidation even if serial init failed, reserved-slot revocation and non-wrapping ticket publication epoch. No new task, heap allocation, route, capacity or stack-size change; Basic cache/currentness retained.
- **Validation:** `python3 tests/web_session_store/run.py` and `python3 tests/web_session_store/run.py --serial` pass using OpenSSL SHA-256 and deterministic dependency interleavings. Integration exercises production transport private steps with the real store, not real HTTPD sockets/tasks. Expanded checks cover isolation, mismatched identity fields, expiry/lost notifications/DB failure, close-queue retry, slot reuse and mint/admission races. Optional `--serial --sanitize` cannot link because this host lacks ASan/UBSan runtime libraries and static archives; not a pass.
- **Build/resources:** Final source `pio run` passed in 7.92 seconds at **95,260 B RAM / 1,601,925 B flash**, **+56 / +1,420 B** versus 8D.1 and **+728 / +1,952 B** versus 8D.0. Target symbol/DWARF: ticket 96 B x 4 (+32 B array), slot 1,664 B x 2 (+16 B array), new epoch 8 B; store remains 664 B. Bound resolver frame 192 B excluding callees; no runtime stack/heap reserve claim.
- **Target/handoff:** `pio device list` sees device CDC and USB serial adapter, but this diff was not flashed or exercised on target. Follow `docs/phase8d2_implementation.md` for two-role Basic five-cycle regressions, mutation isolation, five HTTPS restarts, USB/user+admin SSH/UART0 smoke, 15-minute 115200-baud full mix and 60-second cleanup/memory evidence. Numeric floors remain pending. Cookie-route session logout/expiry/origin validation remains dormant until separately requested 8D.3; obtain target sign-off or explicit user decision before stacking it. No later phase work performed.
## Previous Task - Phase 8D.1 Internal Session Primitives
- **Objective:** Phase 8D.1 only. User explicitly requested continuation with remaining reserve/validation gates pending, then supplied a second pre-change full-client-mix sample. No browser auth cutover or continuation to 8D.2.
- **Context:** The user reports that the earlier experimental implementation was moved to a separate branch and `devel` rolled back after repeated agent interruptions, a roughly +10k/-1k-line diff, broken browser login, and severe memory pressure. These symptoms have not been independently diagnosed. Do not import that branch wholesale.
- **Changes completed:** Added `src/web_session_store.{h,c}`, CMake registration and small admitted-HTTPS-start/stop hooks in `web_server.c`. Four static digest-only sessions, copied principals, separate CSRF/origin state, absolute expiry, generation-safe IDs/epochs, invalidation/prune and count-only snapshot APIs. Database/crypto outside portMUX, post-call revalidation. No issuing HTTP callers, ticket/socket/revocation binding, new routes/tasks, UI change or regenerated assets. Added focused `tests/web_session_store/` runner and updated baseline/plan/architecture/code map.
- **Baseline:** Clean `devel` at `af89dd1bd96cdd97d8d57eee7a29f68e3874506b`, PlatformIO espressif32 6.12.0 / IDF 5.5.0, N16R8 release. `pio run` passed in 36.08 seconds: 94,532 B linked RAM and 1,599,973 B flash (historical RAM unchanged, flash +208 B, cause not diagnosed). `pio device list` returned no ports. No agent upload or target/browser test was performed. The user subsequently supplied a runtime heap baseline; stack measurements remain pending.
- **User-provided runtime memory baseline (2026-09-05):** Administrative SSH transcript with one browser serial writer (broker 8), one public-key user SSH observer (broker 9), and one public-key admin SSH session; web 1/2, SSH 2/2, zero tickets, no USB broker client listed. Heap bytes in free/minimum-free/largest-block order: internal 8-bit **39,668 / 13,876 / 18,432**; internal DMA **31,912 / 6,120 / 18,432**; PSRAM **8,138,380 / 8,107,324 / 7,995,392**. Accepted as the runtime baseline, not reserve floors or full-workload validation. Flashed revision, baud, duration, traffic and stack margins were not supplied. See `docs/phase8d_baseline.md` for provenance and remaining checks.
- **Follow-up pre-change full-client-mix sample:** Two web sessions (broker 8 writer, 10 observer), USB broker 11 observer, user SSH broker 9 observer and admin SSH; four broker clients, SSH 2/2, web 2/2, no tickets. Internal 8-bit **41,868 / 13,876 / 18,432 B**; DMA **34,112 / 6,120 / 18,432 B**; PSRAM **8,162,784 / 8,107,324 / 7,995,392 B** (free/minimum/largest). Cumulative web/SSH traffic with no reported transport I/O/protocol/queue failures; one web auth failure and two rejected SSH RX bytes retained without diagnosis. This is not timed-soak or byte-integrity evidence; see baseline record.
- **8D.1 validation/accounting:** Started at clean `02fdeee3453654680c11096c9a6224c26233eced`. `python3 tests/web_session_store/run.py` passes with OpenSSL SHA-256, deterministic dependency doubles and injected callback races; no hardware or real multicore scheduling claim. Final `pio run` passed in 8.90 seconds: **95,204 B RAM (+672 B)**, **1,600,505 B flash (+532 B)**. Target object/DWARF: record 152 B × four, state 656 B + lock 8 B = **664 B**, no module heap/new task/socket/route cost, Basic cache retained. Only init/stop currently survive firmware linker GC; full primitive flash/stack costs become relevant when future callers are added. Independent review found/fixed store reinit by rejected concurrent start. Post-change target run pending.
- **Planned contract decisions:** Four one-hour absolute sessions without idle/sliding renewal, four 120-second pre-login CSRF challenges, four 30-second serial tickets, no live-record eviction and no Basic compatibility after cutover. Strict same-origin mutation/upgrade checks, session-specific logout versus account-wide revocation, bounded global login throttle. These are functional choices, not measured memory budgets or implemented behavior.
- **Next:** Stop at 8D.1. Obtain post-change Basic browser/serial/USB/user+admin SSH/UART0 regression and comparable `memory`/counters/`ssh status` evidence; five serial disconnect/reconnect cycles per role and HTTPS stop/start checks. Timed soak/cleanup and reserve floors remain pending. Next implementation is 8D.2 session-specific ticket/socket binding, only after checkpoint or explicit user decision; M1/M2 pauses remain. Host tests cover dormant primitives, not browser logout/currentness integration. ID/epoch exhaustion, collision rejection and private memory wiping are source-reviewed only.
- **Risks / things to remember:** Six HTTPD client slots include WebSockets; HTTPD infrastructure plus HTTPS and SSH can consume twelve of sixteen descriptors before other services/transients. Existing LRU purge is enabled and must not evict retained serial to admit M2 admin sockets. Nine current route slots have no spare capacity; the proposed M1 contract adds five. Cookie sessions, tickets, sockets and request scratch are separate costs. Only SSH exposes stack margin today. Numeric runtime floors and per-chunk/cumulative budgets remain pending, not guessed.
## Previous task — mDNS (target checks still pending)
- **Objective:** Announce a configurable `sak-<suffix>.local` hostname through mDNS when Wi-Fi STA has an IPv4 address, without changing the Wi-Fi NVS blob schema.
- **Relevant files:** `src/mdns_config.{c,h}`, `src/mdns_service.{c,h}`, `src/mdns_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/main.c`, `src/CMakeLists.txt`, `src/idf_component.yml`, `dependencies.lock`, completion and command documentation.
- **Findings:** `wifi_manager` already serializes all meaningful STA transitions through its permanent task; callbacks only enqueue events. This is the appropriate lifecycle owner for mDNS, while a separate configuration module preserves the existing `wifi_app/config` wire format.
- **Decision:** Persist a fixed v1 record under `mdns_cfg/config`, separate from Wi-Fi configuration. Defaults derive a safe lower-case hexadecimal suffix from the STA MAC. The manager initializes mDNS at most once after validating `IP_EVENT_STA_GOT_IP`; the managed component's own handlers withdraw/restore the STA responder across connectivity changes, and online hostname changes use `mdns_hostname_set()` without teardown. Initialization failure is latched instead of retried because the resolved upstream 1.12.0 component has an unsafe partial low-memory initialization path. mDNS errors cannot fail Wi-Fi, UART0, UART1, or native USB.
- **Changes completed:** Added the `espressif/mdns` managed dependency (resolved to 1.12.0 on IDF 5.5), mDNS config/service/console modules, `mdns status|suffix|save|load|defaults|reset`, completion, CMake integration, and command/architecture documentation. Minimized the component to STA-only responder use, moved general allocations to PSRAM, retained the internal task stack, and removed reconnect-time free/reinit churn. Final `pio run` passes at 94,532 bytes linked RAM and 1,599,765 bytes flash.
- **Remaining work:** Target-hardware verification: associate a station and resolve the default `sak-<mac>.local`; change/save/load a suffix and confirm live reannouncement plus reboot persistence; stop Wi-Fi or remove the STA lease and confirm the record withdraws. Verify serial, native USB, and UART0 remain available if mDNS initialization fails.
- **Risks / things to remember:** Hostnames are STA-only and are intentionally not announced by fallback AP mode. NVS changes to `mdns_cfg/config` are independent of the unchanged `wifi_app/config` blob. mDNS remains allocated after first successful initialization (including its internal 4 KiB task stack) to avoid fragmentation and unsafe repeated initialization; measure free/minimum/largest internal heap and mDNS stack margin during reconnect stress.
### Handoff template
- **Objective:**
- **Relevant files:**
- **Findings:**
- **Decisions made:**
- **Changes completed:**
- **Remaining work:**
- **Risks / things to remember:**
Validation completed: independent Python local-link/anchor audit PASS across 29 authored Markdown files (122 local links, 53 Markdown fragments); 213 authored source/test/document files checked with zero obsolete Phase 8D filename references. Earlier path audit resolved127 expanded navigation references. GPT logs, vendored/generated trees and remote URL fetching excluded. Twenty obsolete phase documents removed, with no forwarding stubs/archive dump. Git diff/scope checks confirm documentation-only changes. No firmware tests/build/device commands run.
+55 -37
View File
@@ -2,6 +2,18 @@
Only constraints supported by implementation or current project documentation belong here. When original rationale is unknown, the entry describes the observable constraint without inventing intent.
## Display configuration has an owner reservation separate from button activity
**Decision:** `local_status_ui` owns a nonwrapping configuration generation and a zero-wait reservation shared by typed Display and CLI/legacy Apply. NVS runs outside timing critical sections; Save stabilizes selected bytes and Reset commits defaults before RAM publication. Load retains canonical fallback. Buttons/diagnostic holds update activity, not configuration generation.
**Consequence:** Compare before mutation, never overwrite intervening CLI edits, and do not make physical panel presence a configuration prerequisite. A successful config API is not proof of display IO. [Owner and persistence contract](../web_administration.md#serial-and-display).
## Typed Network edits preserve manager ownership and current secret bytes
**Decision:** HTTPD uses zero-wait secret-free projections and an ID-only dispatcher. Wi-Fi compare/merge/whole-candidate validation and queue-before-publication preserve omitted secrets and reject stale changes; its manager alone owns radio/reannouncement. mDNS generation/persistence is independent. Save stabilizes selected RAM; Wi-Fi Load is stored-only, not fallback-secret generation.
**Consequence:** Keep SSIDs reversible bytes, omitted/Replace/disabled-STA Clear distinct, AP clear denied, and Next profile separate from editor selection. mDNS may report RAM applied but reannouncement not queued, without rollback. A one-second queued-secret timer is not hard cancellation; `accepted` is not online/DNS. Recovery and no automatic replay are correctness requirements. [Network contract](../web_administration.md#network).
## One broker mediates all production serial transports
**Decision:** USB CDC, WebSocket, and role-`user` SSH access UART1 through `session_broker`; transports do not independently own the serial service.
@@ -20,6 +32,8 @@ Only constraints supported by implementation or current project documentation be
**Consequence for future changes:** Do not replace fan-out with a blocking shared queue. Any added transport must tolerate partial/no-progress reads and expose drop/backpressure counters.
**Throughput observation and controlled experiments:** The initial diagnostic baseline used CPU160MHz; a CPU240MHz-only experiment reduced but did not eliminate browser queue overflow. Combining binary WebSocket header/payload into one bounded session-override send eliminated reported drops, and the user signed off230400-baud full-client-mix operation after returning to160MHz. Retain the combined send, not the frequency increase; evidence and limits are in `current-state.md`. Preserve scheduling/priorities and 4096/512-byte broker/web buffers while gathering per-client HWM/drop attribution and independent opt-in web binary-TX timing. Fixed-slot epoch/generation-fenced aggregates avoid stale attribution; no new runtime allocations. Clear preserves queued data and seeds broker HWM; disconnected rows disappear while global discard counts remain. Callback timestamps precede the transport lock; synchronous send return is not peer receipt. Completion-to-read intervals include broker/mutex/control work and possible idle, even when the first read is nonempty; never label them pure scheduling latency or proof of backlog at completion. Compare enabled/disabled target captures before drawing overhead conclusions. Contracts and reproduction: [throughput diagnostics](../web_throughput_diagnostics.md).
**Relevant files:** `src/session_broker.c`, `src/session_broker.h`, `docs/roadmap.md`
## Physical UART ownership and logical writer ownership remain separate
@@ -34,15 +48,15 @@ Only constraints supported by implementation or current project documentation be
## Resource IDs are generation-safe
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, and user principals carry generations or random stable IDs to reject stale references and slot reuse.
**Decision:** Broker/SSH/WebSocket slots, originating web sessions, queued admin operations and account principals carry distinct generation/identity fences. Browser cookie-session identity is not interchangeable with account identity. Invalidate session records before socket cleanup and retain authoritative currentness checks even when notifications fail.
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation.
**Consequence:** Never turn selected transport IDs into arbitrary broker IDs/fds, rebase a stale confirmation or wrap a published token. Exhausted broker/SSH slots retire; operation/reservation IDs do not reuse; saturated service/lease generations fence ABA without disabling canonical recovery. Reboot invalidates old logins. Validate/reserve at the owner immediately before effects, not snapshot-check/unlock/unconditional mutation. [Broker](../web_administration.md#broker-and-contextual-controls) and [service](../web_administration.md#service-lifecycle-and-identity-rotation) contracts.
**Consequence for future changes:** Preserve transport-slot generations and account-authentication generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation.
## Confirmed writer transfer compares a lease version inside the broker lock
Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs in serial tickets/slots. 8D.3 rejects zero IDs; Basic authentication/cache are removed. Session-specific cleanup must not become account-wide cleanup; account-name notification intentionally covers deletion/recreation. Invalidate cookie records before requesting transport cleanup, and retain authoritative session/principal checks when notification fails. The transport epoch cancels in-flight ticket publication without taking store and transport locks together.
**Decision:** Compare the connected nonreused target and separate lease generation inside the force-writer lock. A client ID alone cannot fence release/reacquire ABA. Lease generation saturates, survives counter clear and advances before advisory event delivery, potentially twice for force transfer.
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`
**Consequence:** Saturation rejects typed assignment but preserves ordinary request/release/disconnect and recovery force. Accepted serial TX is not recalled. Live contextual refresh must retain the selected versions and sticky stale/absence state until explicit reselection. [Wrap and UI contract](../web_administration.md#broker-and-contextual-controls).
## UART0 is the physical recovery authority
@@ -76,63 +90,61 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
## Selected self-affecting admin SSH actions use bounded deferred control
**Decision:** Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset are deferred until command state and administration/transport application buffers appear drained, with a ten-second limit and short final delay.
**Decision:** SSH self-close/reboot/lifecycle/host-key actions use existing bounded application-drain control; browser shell stop/reboot and typed certificate handoff use owner adapters, not command replay. Drain is not peer receipt. Crypto/NVS browser rotation runs on the existing dispatcher, not the small control stack; executing slots remain reserved across self-detach.
**Rationale/evidence:** `admin_ssh_console` has a separate bounded control task and pending-action state. The check is a best-effort application-buffer heuristic, not peer-delivery confirmation. User account mutations and their immediate revocation calls do not use this path.
**HTTPS typed ACK:** Successful synchronous send return precedes one nonreused-ID HTTPD callback to the existing dispatcher. No captured request/fd/reusable operation pointer or lifecycle wait on HTTPD. Lost accepted work retains one reservation until callback or successful destruction; never release speculatively on timeout. Original-login/current-admin/dequeue deadline checks precede admission. Reserved restart may deliberately invalidate that login; subsequent revocation is not cancellation. Reboot invokes canonical `esp_restart()` outside locks without self-console cleanup.
**Consequence for future changes:** Actions that would invalidate their own SSH transport should integrate with deferred control when acknowledgement preservation matters. Prevent new input while an action is pending, keep the wait bounded, and do not describe it as guaranteed delivery.
**Identity replacement:** Compare/reserve service then task-bound nonreused identity token, shared by canonical/direct callers. Crypto/NVS run outside security locks. HTTPS commits before stop/start; SSH stops before commit/restart. Failed SSH stop skips mutation/start; persistence failure can follow client disconnection. Committed material is never rolled back on restart failure. SSH owner retains context until all slots retire; start rejects orphan handles. Public fingerprint projections do not authorize mutation, and stored/served HTTPS identity can differ after failed stop.
Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutable owner adapter on the existing control task, outside console locks. Tokens include a transport namespace; owners revalidate full identity and marshal to their transport APIs. `SELF_CLOSE` targets the invoking frontend while existing SSH action meanings remain unchanged. Unsupported actions must fail before side effects. The two console slots remain a shared bounded pool, with no hypothetical browser capacity allocated. The 8D.5 prerequisite additionally requires owner currentness on the dispatcher, outside console locks, before commands and during prompts; account currentness alone cannot establish originating browser-session liveness. Recheck token identity after external validation, reject revoked submitted replies, and wipe consumed output. Polling is not a hard cancellation deadline and cannot roll back arbitrary handlers; owners retain admission/input/output/lifecycle responsibilities.
**8D.7 first slice:** WEB also supports reboot and explicit HTTPS stop on the existing control task, with originating-session/principal validation after drain/delay. Stop is service-wide, not admin-socket-only; serial isolation applies to selector/SELF_CLOSE, not explicit HTTPS shutdown. Buffered input observed during deferral is wiped, including an incoming frame whose payload read races cancellation. Keep unsupported identity/credential/network paths blocked until separately implemented; no new executor or delivery guarantee.
**8D.7 second certificate slice:** Exact parsed browser `web certificate rotate --force` schedules a typed action, not command replay. Use the existing request-queue union and immutable owner `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher: crypto/NVS must not run on the 4 KiB control stack. Preserve queue capacity, pending-input gating through execution, token/principal/session revalidation and executing-slot reservation across self-detach. Zero mask preserves legacy SSH execution. WEB revalidates before transactional certificate commit → stop → start; early errors short-circuit and failed stop retains HTTPD ownership without start. Lifecycle failure after commit does not restore the old identity. Browser trust/relogin and UART0/SSH recovery are explicit operational consequences; USB/SSH are not stopped. Bounded acknowledgement/drain is neither an execution deadline nor receipt proof. No stack-size/task/route expansion; owner mask and local scratch still need target accounting/high-water evidence, not host sizeof assumptions. Other credential/account/network/SSH mutations remain blocked pending bounded owner slices.
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`, `src/web_admin_transport.c`, `src/web_console.c`
**Consequence:** Explicitly communicate partial effects, trusted UART0 fingerprint verification and fresh HTTPS login after restart. Preserve CLI recovery/reset but do not add browser Reset/export as if it were ordinary rotation. Native USB is independent UART1, not administration or uninterrupted reboot. [Lifecycle/identity contract](../web_administration.md#service-lifecycle-and-identity-rotation).
## Authentication uses copied principals and fail-safe currentness checks
**8D.5 web owner extension:** Browser and runtime SSH admission allocate from the same two console slots; a physical SSH slot is not a console index. WEB supports owner-relative self-close only and rejects unsupported network/lifecycle/account mutations at parsed command policy before execution. One web-admin socket and two tickets do not increase six-socket HTTPD capacity; disable LRU rather than evict retained serial clients. The optional owner uses one PSRAM-only payload and ESP timer scheduling, not a new task. HTTPD alone sends/shuts down its verified current fd. Do not use IDF's queued raw-`sock_db *` close from admin polling: free/reuse before that work executes could close a replacement. Detach must fence queue submissions before HTTPD stop; retire queued markers only after successful stop, retaining ownership across failures. No browser UI or generic HTTP command runner is part of this boundary.
**Decision:** Network sessions retain secret-free copied principals. Database/account ID/auth generation and originating web-session identity must be current at admission, before sensitive input and during reconciliation. Best-effort target notifications supplement, never replace, these checks and cannot roll back committed mutations.
**Decision:** Network sessions retain secret-free copied principals. Account mutations invalidate generations/IDs; after commit, the command layer requests best-effort targeted transport revocation, while ongoing currentness checks are authoritative.
**Consequence:** Shared remote-console slots require transport-qualified tokens and immutable owner adapters. Validate owner currentness outside console locks, then recheck identity. Owner-side HTTPD/SSH IO and generation-safe cleanup remain mandatory; session liveness checks do not cancel executing handlers. Browser-shell permissions are parsed and narrower than typed Settings. [Authentication](../web_administration.md#authentication-and-admission), [console policy](../web_administration.md#browser-shell-policy).
**Rationale/evidence:** `user_database` issues principals without secrets; web/SSH check currentness during admission and active sessions. Mutating console paths call transport revocation hooks.
## SSH admission budgets survive service and session lifetimes
**Consequence for future changes:** Do not retain pointers to database records or treat login as permanently authoritative. New authenticated sessions/transports must revalidate at admission, before sensitive input, and periodically or on relevant events. Database mutation APIs alone do not perform transport notification, and notification failure must not roll back an already committed mutation.
**Decision:** Three fixed-size owner-only token buckets independently admit handshakes, password/signed-key requests and unsigned probes. Budgets last for the boot, not the slot/service/counter epoch. Rejection closes without sleeping; no per-account/IP storage or persistent lockout is added. Signed-key completion has an explicit pending-result marker and retains authoritative principal checks. Pinned library version/feature/source-contract tests protect callback order; keyboard-interactive is explicitly rejected.
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
**Consequence:** This bounds admitted work but permits global-budget starvation; restarting SSH is not an immediate recovery override. Established streams bypass admission, while actual load/latency still needs whole-phase device evidence. Counters are observations, never enforcement state. [Policy and tests](../security_hardening.md#9b-ssh-admission-and-credential-handling).
## Hidden input must not accept a truncated credential
**Decision:** Hidden console prompts reject overflow/unsupported bytes at submit, wiping output; rejection stays sticky after edits. Submission, Backspace/Delete and Ctrl-C retain their roles. Visible CLI editing is unchanged. Consumed SSH admin RX and accepted TX spans are wiped without touching pending retry bytes; slot retirement securely wipes before restoring generation/sentinels.
**Consequence:** Overlong/unsupported pastes must be retried; caller errors prevent prefix persistence. Application wipes do not establish library/stack/PSRAM zeroization. The pinned wolfSSH positive-send contract is copied/consumed bytes, not peer receipt. UART0 paired-CRLF timing remains a target check. Tests: `tests/hidden_input/run.py`, `tests/ssh_auth_transport/run.py`, `tests/wolfssh_auth_contract/run.py`.
## Typed serial mutations share the administration dispatcher
**8D.9:** HTTPD performs bounded typed admission/result reads only; serial reconfiguration and NVS execute on the existing dispatcher so CLI commands cannot interleave. One global pending slot rejects concurrent work; copied session/principal plus non-reused ID fence stale queued work. A 30-second deadline is checked on dequeue, not a cancellation timer or execution limit. Admitted mutations may finish after revocation; completed results can be replaced. Keep explicit uncertain-outcome recovery and never automatically retry mutations. Apply/Defaults are RAM-only, Save persists working device state rather than browser drafts, and Reset follows canonical apply/persist/best-effort-rollback ordering. Navigation preserves broker clients/writer lease, while explicit serial reconfiguration can discard serial-service pending data. No generic command runner/job history is exposed. See `src/web_serial_settings.{c,h}` and `docs/phase8d9_implementation.md`.
**Decision:** Typed domains queue IDs to the existing serialized dispatcher, never CLI strings or secrets. One original-login slot per domain and a nonreused ID fence stale work; session/deadline checks precede canonical admission. Results are replaceable observations, not durable history/idempotency.
**Consequence:** No automatic mutation replay, including after navigation, timeout or logout. Ordinary deadlines limit dequeue admission, not execution. Accounts/Network queued-secret timers wipe only non-executing inputs; locals wipe after admitted work returns. Explicit RAM/NVS/reset semantics and partial-effect uncertainty must match each canonical owner. [API bounds and lifetime](../web_administration.md#typed-settings-api-and-operation-lifetime).
## Typed account selection is checked inside the database mutation lock
**8D.11:** Apply the same conditional target identity contract to authorized-key add/delete/clear, sharing canonical CLI validation/commit paths. Expose fingerprint metadata only through a zero-wait snapshot; never return stored key blobs. Treat key indices as stable, potentially sparse slots, not response-array positions. Listing uses protected JSON POST to reuse bounded target admission, not a new query parser. Public-key import is bounded to 384 decoded text bytes within the existing 768-byte body, with canonical blob/curve validation on the existing dispatcher. Target revocation/self uncertainty and browser-shell restrictions are unchanged. See `docs/phase8d11_implementation.md`.
**Decision:** Target username/account ID/auth generation compare occurs inside the canonical mutation lock for role/delete/password/key changes. HTTPD uses compact zero-wait secret-free projections, not the blocking CLI snapshot. Successful changes notify only the target's web/SSH sessions, including self.
**Current 8D.10 slice 2:** Extend conditional identity checks to password replacement; create uses canonical duplicate/capacity/commit policy. Keep generation separate from commit: the protected bodyless generated-value POST returns one transient value, performs no mutation and retains no retrieval state. Browser saved acknowledgement is context-bound UX, not delivery proof or server authorization. Queued credentials require a one-second periodic timer to cancel/wipe non-executing work at the 30-second deadline plus scheduling latency; execution copies then wipes shared inputs, with local wiping after admitted database work returns. Neither timer nor logout cancels admitted commits. Self password/role/delete uses immediate canonical target revocation, not deferred acknowledgement: 401/disconnect is uncertain and requires relogin/inspection before any explicit retry. Generation is independently optionally registered, preserving failure isolation and restart behavior at 23 handlers. No shell restriction change, secret result/history, new executor or 8D.11 work. Implementation is complete, host-tested/build-verified, not target accepted; timer runtime costs remain unmeasured. See contracts, build and attributed test evidence in `docs/phase8d10_implementation.md`.
The following first-slice exclusions are historical and superseded by slice 2:
**8D.10 first slice:** Accounts HTTPD routes expose a compact zero-wait list without password/key data and submit role/delete IDs to the existing dispatcher. Do not use the larger blocking CLI snapshot on HTTPD. Initiating-session currentness is checked before operation admission; target username/account ID/auth generation is compared under the database lock before candidate staging. Conditional and CLI mutations share invariant/commit logic. Notify only the target's web/SSH sessions after successful calls; notification failure does not undo persistence. Separate bounded Serial/Accounts slots do not create another executor. Completed results remain replaceable, no mutation auto-retry, and navigation is not cancellation. Self-target and create/password/generated-secret delivery are intentionally excluded until the next slice defines safe delivery/reconnect semantics. `src/web_account_settings.{c,h}`, `src/user_database.{c,h}`, `docs/phase8d10_implementation.md`.
**Consequence:** Separate generated-value delivery from mutation and retain no retrieval history; context-bound saved acknowledgement is UX, not receipt proof. Self-revocation can deny results, so 401/disconnect cannot mean success or cancellation. Key slots are stable and sparse, fingerprint-only on output; import shares canonical validation. Final-admin invariants and UART0 provisioning/recovery remain. [Accounts contract](../web_administration.md#accounts-and-authorized-keys).
## Browser authentication has a narrow version-pinned HTTPD boundary
**8D.8 read-only settings:** Reuse bodyless GET cookie/current-admin policy and the existing bounded browser API/errors; no CSRF mutation semantics on a read. Obtain working serial config/running atomically with a zero-wait existing serial mutex, never block HTTPD on stop/reconfiguration or inspect NVS. Navigation changes view/input only, preserving both terminal sockets/lease/output; Settings session validation must not supersede serial-admission checks. One optional exact-GET URI raises only handler capacity to 17. The private adapter stages both descriptor/name allocations before publishing, avoiding the installed 5.5.0 public registration's freed table pointer on strdup failure. Serialized startup/exact matcher only, normal HTTPD allocation/free ownership; re-audit this boundary on SDK changes. Existing public registration callers are not refactored by this phase.
**Decision:** `web_httpd_adapter` alone accesses private IDF 5.5.0 parsed-header/session state. Reject duplicate/ambiguous headers; defer 101 until cookie/Origin/ticket/transport admission; wipe consumed scratch while preserving unread bytes. Stage optional Settings descriptor/name allocations before publication. Compile header/ticket debug logging out.
**8D.6 terminal separation:** Browser selection never reconnects serial or requests/releases a writer lease. Hidden connected terminals continue draining with separate bounded scrollback/pending writes and visible browser-drop accounting; only selected keyboard input is sent. Admin admission/reopen is explicit, close is isolated, and logout/expiry/pagehide tears down both routes. Keep the two page-lifetime input subscriptions stable across switches and remove socket callbacks on close. UI role hiding complements, never replaces, backend authorization. Existing unsupported WEB lifecycle/account-command restrictions remain for 8D.7.
**Consequence:** Re-audit SDK assumptions on upgrade; never patch around Origin `null` by weakening same-origin policy. Browser authentication POST uses CORS mode with fixed same-origin URLs/credentials because no-referrer non-CORS POST can serialize Origin as null. Digest-only cookie/challenge sessions replace Basic without fallback or live-record eviction. CSP loader hashes and authored scripts change atomically. Navigation preserves terminals/lease, while session-identity changes require a clean document before showing retained buffers. [Authentication and terminal contracts](../web_administration.md#authentication-and-admission).
**Decision:** 8D.3 uses `web_cookie_auth` plus digest-only session/challenge stores, mandatory Origin/CSRF mutations and no live session/challenge/ticket eviction. Four one-hour absolute sessions deliberately interrupt long serial connections at expiry. No Basic compatibility path remains.
## Dependency corrections are reproducible build inputs, not local SDK edits
**Browser Origin serialization:** Authentication POST fetches use `mode: 'cors'` while retaining fixed same-origin URLs, `credentials: 'same-origin'`, redirect rejection and CSP `connect-src 'self'`. Under `no-referrer`, non-CORS POST mode can serialize Origin as `null` (confirmed in Firefox during M1 testing). Do not fix that by accepting null server-side or weakening CSP/referrer policy; no cross-origin server permission is added.
**Decision:** Root CMake installs four exact-source-hash corrections after IDF target creation, rendering copies in the build tree and replacing each original target source exactly once. Original notices, includes and source compile properties are retained. Changed hashes/versions/missing/ambiguous sources fail configuration, with no unpatched fallback. Tests verify generated bytes and actual compiler inputs. [Registry and audit](../security_library_review.md).
**HTTPD boundary:** `web_httpd_adapter` alone includes private ESP-IDF 5.5.0 structures. Public request getters expose only the first field, so the adapter validates bounded parsed headers/rejects duplicates. `/ws/serial` is an ordinary GET until authenticated transport admission explicitly sends 101 and installs the frame handler; automatic HTTPD upgrades happen before URI handlers. Preserve right-aligned unread pending bytes when wiping request memory. Two Set-Cookie calls append pointer-backed fields, whose distinct buffers must survive through send. HTTPD logs above ERROR are compiled out to prevent header/ticket exposure.
**Consequence:** Upgrades need source/lifetime/feature re-audit, not just refreshed pins. Installed vendor code alone is not authoritative for overridden functions. Corrections cover HTTPS failure cleanup/key wiping, HTTPD scratch ownership, TLS server-local policy and SSH password packet bounds/wiping. TLS client defaults and global primitives remain unchanged. This is normal reproducibility checking, not tamper-resistant attestation.
**Ordinary HTTPS idle lifecycle:** Keep six sockets and LRU disabled. `web_httpd_idle` queues at most one owner sweep each second; `web_httpd_adapter` alone reads the installed successful request-plus-purge completion marker. Fifteen seconds of observed ordinary idle (three normal five-second browser polls), current SDK WS/async exemption, pending/readable-input checks and synchronous TLS-create fd invalidation authorize current-owner `shutdown`, never queued `sock_db *` close. Do not use response events, diagnostic wrappers or connection age as the completion/idle boundary. All ordinary response work must finish synchronously or retain the SDK async exemption. Submission fencing precedes HTTPD stop; only successful stop retires queued state and admits a nonreused generation. No tracing dependency, forced per-response close, hard request deadline, arbitrary admission eviction or capacity increase. A reported queue failure retries; accepted-but-lost UDP work stays reserved until successful restart rather than accumulating potentially delayed probes. Exact safety/liveness limits and target checklist: `docs/https_idle_cleanup.md`.
## Retired library storage and protocol defaults have explicit policies
**Consequence:** The version guard is not a source-hash guarantee. Re-audit layout, scratch/pending ownership, logging and handshake/frame dispatch on SDK changes; do not scatter private accesses through application code or assume host doubles prove real socket behavior. No SDK patch is currently applied. See `docs/phase8d3_implementation.md` for verification and target gates.
**Decision:** wolfSSL/wolfCrypt hooks use reviewed unpoisoned IDF5.5 usable allocation extents to wipe before free. No header overhead; shrink wipes tail but retains capacity; growth allocates/copies/wipes, retaining the old block on failure. Poisoned heaps and dynamic IDF TLS buffers are compile-rejected pending separate lifetime audits. Explicit static TLS/SSH allowlists replace negotiation defaults, without identity migration or weakening user-database authorization.
**Relevant files:** `src/web_cookie_auth.{c,h}`, `src/web_session_store.{c,h}`, `src/web_httpd_adapter.{c,h}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/CMakeLists.txt`.
**Consequence:** Whole-buffer wipes and old-plus-new allocation peaks need combined target measurement; no blanket claim covers live inline residue, compiler spills or every crypto temporary. Legacy-only cipher/KEX clients can lose access; policy setter failure must free unpublished context rather than fall back. Web's early quota check does not change verification counts, service-restart resets or challenge fairness. [Exact algorithms, cleanup and limits](../security_library_review.md).
## Security material and configuration use bounded, versioned NVS records
@@ -140,13 +152,13 @@ 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.
**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`.
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Legacy credential synchronization and reconciliation are removed. Missing user storage commits empty; valid user v1 bytes remain compatible, with private `v1_admin_marker` derived from admin count, not a public bootstrap contract. HTTPS v1 (1,392 bytes) migrates through a private validated reader to TLS-only v2 (1,340 bytes), preserving exact DER/fingerprint/generation and committing before publication. Failures fail closed without fallback regeneration or overwriting rejected records. See [legacy compatibility](../legacy_credential_removal.md).
**Relevant files:** `src/serial_config.c`, `src/wifi_config.c`, `src/mdns_config.c`, `src/mdns_service.c`, `src/local_ui_config.c`, `src/web_security.c`, `src/user_database.c`, `src/ssh_security.c`
## NVS is persistence, not a physical security boundary
**Decision:** The current firmware stores Wi-Fi credentials 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. Phase 9 explicitly excludes secure boot and encrypted NVS by user preference; physical extraction and firmware replacement remain outside the supported threat model, not deferred guarantees of this phase.
**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.
@@ -154,6 +166,12 @@ The following first-slice exclusions are historical and superseded by slice 2:
**Relevant files:** `partitions.csv`, `README.md`, `src/web_security.c`, `src/ssh_security.c`, `src/wifi_config.c`
## Supported builds do not export crash memory
**Decision:** Phase 9A requires disabled core dumps and silent panic reboot, and forbids panic/runtime GDB stubs and OCD-aware panic handling. `src/security_build_policy.c` checks resolved SDK configuration at compile time; defaults alone are insufficient. Development, test and production handling profiles share this baseline, with no bypass flag.
**Consequence:** Decoded panic traces/register output are deliberately lost; ordinary boot/status diagnostics remain and are not universally sanitized by this policy. No eFuse, physical JTAG restriction, partition change or retroactive dump clearing is implied. Target panic/recovery validation remains necessary. [Policy and tests](../security_hardening.md).
## Wi-Fi callbacks enqueue; the manager owns policy
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, reconciliation, and station mDNS announcement transitions. mDNS initializes at most once, remains allocated across transient disconnects while its component handlers withdraw/re-enable the STA interface, and treats failure as nonfatal.
+20 -6
View File
@@ -2,7 +2,9 @@
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 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.
Browser admin uses the same dispatcher with a [narrower parsed frontend policy](web_administration.md#browser-shell-policy), independent of typed Settings permissions. It supports bounded deferred `reboot`, `web stop`, exact `web certificate rotate --force` and owner-relative `exit`. Drain (up to ten seconds plus 200 ms) is best-effort application-buffer acknowledgement, not peer receipt or an execution deadline; pending input is discarded. Certificate work runs on the existing dispatcher through the shared service-before-identity reservation, commits before stop/restart and never rolls back a committed identity after lifecycle failure. Failed stop retains ownership and skips start. Verify changed trust through UART0 `web certificate info`, recover with UART0/admin SSH `web stop` / `web start`, then sign in freshly. HTTPS-only actions leave SSH/native USB/UART0 independent; reboot affects every transport and loses unsaved RAM.
Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/`mdns` allow status only. Browser `user` allows status/list/show and interactive add/password plus forced role/delete for **other accounts**, not self/generated/key/recovery commands. Restricted SSH stop/disconnect/reset/host-key mutation remains unavailable in the browser shell. Typed Accounts/Network/SSH settings separately provide their documented bounded workflows; this is not shell parity. See [web administration](web_administration.md) for lifecycle/API ownership and uncertainty.
## System
@@ -30,7 +32,7 @@ Browser-admin uses the same dispatcher with temporary 8D.7 restrictions. It now
| `user key clear <username> --force` | Delete all public keys for an account. |
| `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. Hidden console prompts reject overflow or unsupported bytes rather than accepting a truncated/normalized prefix, even if later editing reduces the length; submit or cancel and start again. CR/LF submits, Backspace/Delete edits, and Ctrl-C cancels. 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.
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.
@@ -44,10 +46,12 @@ Missing `user_db/database` storage is committed empty. On UART0 run `user add <u
| `display set dim-seconds <0..86400>` | Set the RAM inactivity delay before contrast drops to `1`; `0` disables dimming. |
| `display set off-seconds <0..86400>` | Set the RAM inactivity delay before the OLED switches off; `0` disables automatic off. |
| `display save` / `display load` | Save the working aging settings to NVS or load them. |
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or apply and persist them. |
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or save defaults first and then apply them. |
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
Display settings require an available local UI task, not an attached panel. CLI and browser **Settings → Display** share the public configuration owner; concurrent mutations can report busy, and browser operations reject an intervening configuration edit rather than overwrite it. Save persists working RAM, not browser drafts. Load selects defaults when saved storage is absent/incompatible without rewriting NVS. Reset storage failure leaves RAM unchanged (commit-before-publication, no RAM rollback). Browser timeout/navigation does not cancel already-admitted work; Check Result and Refresh before retrying. [Display settings contract](web_administration.md#serial-and-display).
## Serial service
| Command | Description |
@@ -66,7 +70,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control,
| Command | Description |
|---|---|
| `broker status` / `broker clients` | Show broker state or connected clients. |
| `broker counters` / `broker clear-counters` | Show or clear broker counters. |
| `broker counters` / `broker clear-counters` | Show global totals plus active-client ID/type/pending/HWM/UART/queued/read/dropped; clear counters and seed HWM from current pending bytes without draining output. |
| `broker connect <name>` / `broker disconnect <client-id>` | Create or remove a console test client. |
| `broker request-writer <client-id>` / `broker release-writer <client-id>` | Request or relinquish the single writer lease. |
| `broker force-writer <client-id|none>` | Administratively assign or clear the writer lease. |
@@ -74,7 +78,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control,
| `broker read <client-id> [maximum-bytes]` | Read queued serial output for a client. |
| `broker events <client-id>` | Show ownership and connection events for a client. |
Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output.
Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output. HWM is bounded by 4,096 bytes; read counts transport handoff, not peer receipt. Disconnected client rows disappear, while global totals retain traffic and unread-output discards until cleared. Capture counters before disconnect. `broker read` consumes queued data and must not be used as a throughput diagnostic probe. See [Web throughput diagnostics](web_throughput_diagnostics.md).
## Native USB CDC-ACM
@@ -126,11 +130,15 @@ When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sa
| `web` / `web help` | Show web-service command usage. |
| `web status` | Show HTTPS and WebSocket state. |
| `web start` / `web stop` | Start or stop HTTPS service. |
| `web counters` / `web clear-counters` | Show or clear web counters. |
| `web counters` / `web clear-counters` | Show or clear ordinary HTTPS/serial WebSocket counters, independently of performance capture. |
| `web diagnostics enable\|disable\|show\|clear` | Independent opt-in admission tracing and post-TLS occupancy; not serial throughput timing. |
| `web performance enable\|disable\|show\|clear` | Default-disabled per-slot binary TX timing/count aggregates. Disable freezes; enable resumes; clear preserves enabled state. Each control operation fences in-flight samples with a new epoch. All actions print a snapshot. |
| `web certificate info` | Display certificate identity and fingerprint. |
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. Browser admin defers commit and HTTPS restart; both browser routes close and new certificate trust/relogin is required. UART0/admin SSH behavior is unchanged. |
| `web reset --force` | Explicitly replace HTTPS certificate/private key only, including missing, incompatible or damaged material; never changes users. |
Use UART0 for quiet throughput captures (authenticated admin SSH also exposes these commands). Performance output includes current-epoch pending age and count/sum/estimated-average/max timings for queue entry to callback entry, synchronous send, and completion to drain return. These are not peer acknowledgements or scheduler-only measurements; nonempty intervals may include idle. No secrets or serial payloads are printed. See [Web throughput diagnostics](web_throughput_diagnostics.md) for exact semantics, saturation limits and the reset/burst/drain/disable/capture-before-disconnect recipe.
HTTPS listens on port 443 only. Authenticate with any current user-database username/password; both `user` and `admin` roles receive the existing status and browser-terminal interface. The device serves vendored xterm.js without Internet access. Browser sessions use one-time account-bound tickets, binary WebSocket frames, and the broker's one-writer rule. The combined **Connect**/**Disconnect** control closes the current WebSocket and pauses automatic reconnect when active; after a user-paused disconnect, it changes to **Connect** to resume connection attempts. Account mutations revoke only that account's tickets and sessions.
## SSH serial transport
@@ -149,6 +157,12 @@ HTTPS listens on port 443 only. Authenticate with any current user-database user
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. A `user` receives the broker-backed UART1 serial stream. An `admin` receives the administration shell instead, does not become a broker client, and cannot acquire a UART1 writer lease.
SSH algorithm policy is explicit: KEX `curve25519-sha256`/`ecdh-sha2-nistp256`, P-256 host key, AES-128/256-GCM ciphers and `hmac-sha2-256` MAC advertisement (GCM authenticates packets). CBC/CTR-only or excluded-KEX-only clients cannot connect. There is no CLI fallback that weakens this policy; do not rotate keys merely to address a negotiation mismatch. [Exact TLS/SSH policy and upgrade contract](security_library_review.md).
SSH admission uses global boot-lifetime token buckets: handshakes and password/signed-key requests each allow a burst of six and refill one token per ten seconds; unsigned-key probes allow twelve and refill one per five seconds. The existing three-counted-attempt failure closure remains per connection. Reconnect, `ssh stop`/`ssh start`, host-key rotation and `ssh clear-counters` do not replenish these budgets. Rate denial closes the authenticating connection, not an established stream. Restrict hostile traffic and allow natural refill; repeatedly reconnecting consumes shared capacity and can prevent other users from logging in.
`ssh counters` separates handshake/verification/probe admissions and rate rejections, attempt-limit closures, backend errors and rejected methods. Admitted work is not necessarily successful or completed; probes/rate denials are not completed `auth-attempts`. These counters expose no submitted credentials and clearing them does not change enforcement. Keyboard-interactive is explicitly rejected, not merely omitted from the advertised list. See [policy and validation](security_hardening.md#9b-ssh-admission-and-credential-handling).
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH.
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.
+189
View File
@@ -0,0 +1,189 @@
# Broader dependency advisory coverage
Review date: **2026-09-16**. Documentation-only, finite snapshot; **not security certification, upgrade approval, or Phase 9 sign-off**.
## Result and authority
**New actionable maintenance finding:** the installed IDF 5.5 Wi-Fi bundle predates the official 5.5.3 correction for management-frame memory corruption. The project's PMF-capable WPA2/WPA3 SoftAP supplies the relevant supported configuration. Prioritize a coherent vendor-bundle update/backport investigation; see the exact evidence and limitations below. This is a current-feature exposure candidate supported by the vendor's release/fix description, **not a demonstrated device exploit or independently audited binary implementation**.
No additional current-path security correction was established for `esp_tinyusb`, TinyUSB, mDNS, LED strip, or the two vendored browser packages in this finite search. That statement does not establish that these dependencies are vulnerability-free.
- [IDF security review](idf_security_review.md) remains authoritative for its detailed mbedTLS findings, DHCP-server/EMS/X.509 backports, WebSocket subprotocol exclusion, and first-page feature dispositions. This document completes the previously omitted **IDF advisory-index pages**, without repeating those audits or claiming a fresh override verification.
- [Wolf security review](wolf_security_review.md), [key-validation review](ssh_key_validation_review.md), and [remaining parser review](ssh_parser_remaining_review.md) retain their scopes and outstanding gates. Wolf advisories were not re-searched here.
- Only `docs/dependency_advisory_coverage.md` was created. No sources, pins, manifests, assets, generated outputs, configuration, builds, tests, or devices were changed/run. Existing build metadata was read, not regenerated; it does not establish executable freshness or device state.
## Baseline actually inspected
Managed manifests were read from installed component directories, not inferred from the application's semver constraints. The application requests `mdns: ^1.8.2`, but installed mDNS is **1.12.0**.
| Dependency | Installed/source identity | Current use |
| --- | --- | --- |
| ESP-IDF | Existing reviewed 5.5.0 baseline; additional original SDK sources and Wi-Fi archives inspected below | Wi-Fi AP/STA, lwIP, HTTPS; existing local security overrides remain part of effective inputs |
| `espressif/esp_tinyusb` | **2.2.1**, manifest commit `8e779566ef71d43928cbf7e125e8eb54bab3f542`, path `device/esp_tinyusb` | CDC device wrapper |
| `espressif/tinyusb` | **0.21.0~1**, manifest commit `7049c58a0e895acc92c6407574b05b5536eddfc8` | Espressif fork, not interchangeable with an unqualified upstream 0.21.0 claim |
| `espressif/mdns` | **1.12.0**, manifest commit `db06b19b7be729c163d346f62ec0eba01047b7f1` | STA hostname responder; no registered DNS-SD services or browser/query application calls |
| `espressif/led_strip` | **3.0.3**, manifest commit `7cd447361ca2f0a1c01aa3089e3031f6171b6c7e` | One WS2812, RMT, no DMA |
| `@xterm/xterm` | **5.5.0** per [asset provenance](../web_assets/SOURCES.md); local JS SHA-256 `1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495` rechecked | Serial and admin browser terminals |
| `@xterm/addon-fit` | **0.10.0**; local JS SHA-256 `bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089` rechecked | Only loaded addon |
Browser version attribution is to the recorded exact-package provenance plus matching local hashes. No npm install, registry dependency resolution, asset download, decompression, or embedded-asset regeneration was performed. Generated compressed arrays were not independently re-hashed in this slice.
Local configuration evidence is `.pio/build/esp32-s3-devkitc-1-n16r8/config/sdkconfig.h`; compilation evidence is a filtered read of the existing compilation database. Source references under `components/` below are relative to `/home/mscholz/.platformio/packages/framework-espidf/`.
## Finite search ledger: URLs checked and stopping rules
The search ended at the following dated snapshot. Empty official repository advisories mean **no published advisories returned by that endpoint**, not no CVEs anywhere, no undisclosed defects, or a security guarantee. GitHub releases and component changelogs are different publication channels; an empty Releases list is not evidence that component versions stopped shipping.
### IDF advisory index: all snapshot pages
Fetched the official HTML index [page 1](https://github.com/espressif/esp-idf/security/advisories), [page 2](https://github.com/espressif/esp-idf/security/advisories?page=2), and [page 3](https://github.com/espressif/esp-idf/security/advisories?page=3): **10 + 10 + 2 = 22 entries**, oldest June 23, 2022, newest September 3, 2026. Page 3 is the terminal listed page. Full advisory descriptions were read from the official [repository advisory API](https://api.github.com/repos/espressif/esp-idf/security-advisories?per_page=100&page=1), not merely titles.
The [API page=2 request](https://api.github.com/repos/espressif/esp-idf/security-advisories?per_page=100&page=2) unexpectedly returned the same 22 entries with no Link header. **It was not treated as an empty-page proof or 22 additional advisories.** The HTML pagination and unique IDs establish the 22-entry snapshot boundary. No unbounded pagination or general web search followed.
### Other official advisory channels
Each URL below returned the stated number of public advisory records, with no next-page Link header:
| Official endpoint checked | Records | Disposition |
| --- | ---: | --- |
| [esp-usb](https://api.github.com/repos/espressif/esp-usb/security-advisories?per_page=100&page=1) | 3 | HID/UVC **host** issues; device CDC exclusion below |
| [hathach/tinyusb](https://api.github.com/repos/hathach/tinyusb/security-advisories?per_page=100&page=1) | 0 | No published records returned |
| [espressif/tinyusb](https://api.github.com/repos/espressif/tinyusb/security-advisories?per_page=100&page=1) | 0 | Fork checked separately |
| [esp-protocols](https://api.github.com/repos/espressif/esp-protocols/security-advisories?per_page=100&page=1) | 0 | mDNS release fixes screened separately |
| [idf-extra-components](https://api.github.com/repos/espressif/idf-extra-components/security-advisories?per_page=100&page=1) | 0 | LED changelog screened separately |
| [xterm.js](https://api.github.com/repos/xtermjs/xterm.js/security-advisories?per_page=100&page=1) | 0 | Browser release fixes screened separately |
### Release/changelog boundary
- **IDF:** [release API page 1](https://api.github.com/repos/espressif/esp-idf/releases?per_page=100&page=1) returned 100 records; [page 2](https://api.github.com/repos/espressif/esp-idf/releases?per_page=100&page=2) returned 64 and no additional 5.5 tags. Screened published stable **5.5.15.5.5** GitHub release bodies for explicit security, vulnerability, overflow, out-of-bounds, corruption, UAF and double-free descriptions; followed the relevant additional candidates below. This is not a line-by-line audit of every bugfix or another IDF branch. [5.5.4 body](https://api.github.com/repos/espressif/esp-idf/releases/tags/v5.5.4) mainly describes a NimBLE regression. The linked full [5.5.4](https://release-notes.espressif.tools/release/5.5.4) and [5.5.5](https://release-notes.espressif.tools/release/5.5.5) release databases returned **no textual content** through the fetch tool: their database-only contents are an explicit coverage gap, not reviewed pages.
- **esp-usb:** [Releases API](https://api.github.com/repos/espressif/esp-usb/releases?per_page=100&page=1) returned zero. Read the official [component changelog](https://raw.githubusercontent.com/espressif/esp-usb/master/device/esp_tinyusb/CHANGELOG.md) through **2.3.0**, and [current manifest](https://api.github.com/repos/espressif/esp-usb/contents/device/esp_tinyusb/idf_component.yml), also 2.3.0. This establishes a changelog ceiling, not proof of registry publication or compatibility.
- **TinyUSB:** [upstream Releases API](https://api.github.com/repos/hathach/tinyusb/releases?per_page=100&page=1) returned 18 records, newest **0.21.0**, whose full release notes were read. [Fork Releases API](https://api.github.com/repos/espressif/tinyusb/releases?per_page=100&page=1) returned zero. [Installed fork commit API](https://api.github.com/repos/espressif/tinyusb/commits/7049c58a0e895acc92c6407574b05b5536eddfc8) identifies the merge “Necessary fixes for TinyUSB 0.21.” No later published upstream release was present. Two legacy-path requests, [upstream](https://raw.githubusercontent.com/hathach/tinyusb/master/docs/info/changelog.rst) and [fork](https://raw.githubusercontent.com/espressif/tinyusb/master/docs/info/changelog.rst), returned 404; [docs listing](https://api.github.com/repos/hathach/tinyusb/contents/docs) then [changelog directory](https://api.github.com/repos/hathach/tinyusb/contents/docs/changelog) resolved the moved layout and confirmed 0.21.0 as its highest listed version. Unreleased commits/PRs were not exhaustively searched.
- **mDNS:** [esp-protocols release page 1](https://api.github.com/repos/espressif/esp-protocols/releases?per_page=100&page=1) returned 100 records and [page 2](https://api.github.com/repos/espressif/esp-protocols/releases?per_page=100&page=2) 37. Post-pin mDNS releases in that finite list are **1.13.0 (September 14)** and **1.13.1 (September 15)**. Their notes and both production-source fix patches were inspected; other monorepo components are outside this slice.
- **LED:** [Releases API](https://api.github.com/repos/espressif/idf-extra-components/releases?per_page=100&page=1) returned zero. Official [LED changelog](https://raw.githubusercontent.com/espressif/idf-extra-components/master/led_strip/CHANGELOG.md) ends at **3.0.3**, matching installed version.
- **xterm:** [Releases API](https://api.github.com/repos/xtermjs/xterm.js/releases?per_page=100&page=1) returned 89 records with no pagination. Read **5.5.0** baseline and **6.0.0 (December 22, 2025)** post-pin release notes. 5.5.0 explicitly lists addon-fit 0.10.0 as compatible. Screened 6.0.0's security-relevant addon fixes and actual integration below; did not mistake npm development/demo dependency updates for packages served by this firmware.
This completes the **declared finite search**, not every possible vendor/CVE channel. NVD, OSV, npm's global advisory database, private advisories, all historical SDK subdependency advisories, every unreleased commit, and all non-security-labelled release regressions were not exhaustively searched. The failed full-release-database fetches remain visible above.
## IDF pages 23: all twelve previously omitted entries
All linked advisory bodies below were included in the fetched official API response. “Not-current-path” does not mean the underlying library is patched.
| Advisory / CVE | Required feature and snapshot disposition |
| --- | --- |
| [GHSA-9j5x-rf36-54x9](https://github.com/espressif/esp-idf/security/advisories/GHSA-9j5x-rf36-54x9), CVE-2026-25508 | BLE provisioning ATT prepared-write length accumulation. **Not-current-path:** Bluetooth/provisioning absent. |
| [GHSA-m2h2-683f-9mw7](https://github.com/espressif/esp-idf/security/advisories/GHSA-m2h2-683f-9mw7), CVE-2026-25532 | WPS enrollee fragment underflow requires initiating WPS PBC/PIN pairing. **Not-current-path:** no application `esp_wifi_wps_*` calls; no enabled WPS setting in the inspected generated header. Ordinary PSK/SAE association is not WPS. |
| [GHSA-hmjj-rjvv-w8pq](https://github.com/espressif/esp-idf/security/advisories/GHSA-hmjj-rjvv-w8pq), CVE-2025-68473 | Bluedroid Classic SDP UUID-array overflow. **Not-current-path:** Bluetooth absent. |
| [GHSA-43gh-7r4f-qp57](https://github.com/espressif/esp-idf/security/advisories/GHSA-43gh-7r4f-qp57), CVE-2025-68474 | Bluedroid Classic AVRCP vendor-command allocation/write. **Not-current-path:** Bluetooth absent. |
| [GHSA-vcw6-jc3p-4gj8](https://github.com/espressif/esp-idf/security/advisories/GHSA-vcw6-jc3p-4gj8), CVE-2025-65092 | ESP32-P4 hardware JPEG header parser. **Not-current-path:** ESP32-S3, no JPEG driver compilation inputs. |
| [GHSA-qhf9-vr2h-jh96](https://github.com/espressif/esp-idf/security/advisories/GHSA-qhf9-vr2h-jh96), CVE-2025-66409 | Bluedroid Classic AVRCP vendor-command read. **Not-current-path:** Bluetooth absent. |
| [GHSA-8mg7-9qpg-p92v](https://github.com/espressif/esp-idf/security/advisories/GHSA-8mg7-9qpg-p92v), CVE-2025-64342 | Original ESP32 Bluetooth controller invalid access address. **Not-current-path:** S3 explicitly outside affected chip family; Bluetooth also absent. |
| [GHSA-9w88-r2vm-qfc4](https://github.com/espressif/esp-idf/security/advisories/GHSA-9w88-r2vm-qfc4), CVE-2025-55297 | BluFi example credential/DH buffers. **Not-current-path:** no BluFi integration, no Bluetooth host. Not a generic flaw in this project's Wi-Fi password handling. |
| [GHSA-hqhh-cp47-fv5g](https://github.com/espressif/esp-idf/security/advisories/GHSA-hqhh-cp47-fv5g), CVE-2025-52471 | ESP-NOW receive underflow. **Not-current-path:** no application ESP-NOW initialization or receive callback. Does not prove opaque Wi-Fi archives are globally fixed. |
| [GHSA-wm57-466g-mhrr](https://github.com/espressif/esp-idf/security/advisories/GHSA-wm57-466g-mhrr), CVE-2024-53845 | ESPTouch v2 constant AES-CBC IV. **Not-current-path:** no SmartConfig/ESPTouch provisioning calls; PSK/SAE manager is separate. |
| [GHSA-22x6-3756-pfp8](https://github.com/espressif/esp-idf/security/advisories/GHSA-22x6-3756-pfp8), CVE-2024-28183 | Physical flash TOCTOU against anti-rollback. **Excluded/not configured:** no enabled `CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK`; physical firmware replacement is outside current review model. No claim of secure boot or rollback protection. |
| [GHSA-7f7f-jj2q-28wm](https://github.com/espressif/esp-idf/security/advisories/GHSA-7f7f-jj2q-28wm), CVE-2022-24893 | BLE Mesh provisioning `SegN` bounds. **Not-current-path:** Bluetooth/Mesh absent. |
Rechecked local evidence: target `esp32s3`/Xtensa; no enabled `CONFIG_BT_ENABLED`; compilation-entry counts `/bt/host/`, `esp_driver_jpeg`, `/esp_tee/` each zero. Targeted application searches for WPS, ESP-NOW, SmartConfig, BluFi and protocomm found no integration. These exclusions do not depend on later release tables implicitly excluding 5.5.0. Revisit before enabling these features.
Together with the ten entries already dispositioned in [the IDF review](idf_security_review.md#bounded-idf-advisory-index-snapshot-disabledunused-features), this accounts for all **22 unique IDs** in the dated official index.
## New release-note finding: Wi-Fi management-frame corruption
**Priority: prompt coherent vendor correction review for PMF-capable SoftAP operation. Not fixed by existing DHCP/mbedTLS overrides.** Reported to the parent during this review.
Official [IDF 5.5.3 release](https://github.com/espressif/esp-idf/releases/tag/v5.5.3) says “Fixed memory corruption issue by ensuring management frames are only encrypted when they are robust management frames,” referencing **`a3927641`**. Fetched [patch](https://github.com/espressif/esp-idf/commit/a3927641.patch) and [commit API](https://api.github.com/repos/espressif/esp-idf/commits/a3927641): merge commit **`a3927641bfd0aedfaab3514cba3d7fb01378ce4d`**, contained patch commit **`caf4cad3b422be5c2f93afeff4cd07796a262b46`**. The different patch-header ID is the contained commit, not an invented match to the merge hash.
The patch description states that when the supplicant sends an authentication response for an already-connected station with installed keys, unconditional management-packet encryption based on `bss->pmf_enable` assumed extra encryption-header space that was not present. It also mentions incorrect allocation due to RSN IE length mismatch; that second trigger was not independently established for the project's short configured IE set.
### Current application prerequisites and original interface
`src/wifi_manager.c:315331`, `configure_ap()`:
```c
wifi_config.ap.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
wifi_config.ap.max_connection = 4U;
wifi_config.ap.pmf_cfg.capable = true;
wifi_config.ap.pmf_cfg.required = false;
wifi_config.ap.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
esp_err_t error = esp_wifi_set_config(WIFI_IF_AP, &wifi_config);
```
`required=false` does **not** disable negotiated PMF. AP/APSTA is a supported runtime policy (`wifi_manager.c:382,646`), not an unused SDK feature; generated `CONFIG_ESP_WIFI_SOFTAP_SUPPORT=1` and WPA3 SAE support are enabled. Exposure is conditional on AP operation and the vendor-described station/key/PMF state. No claim is made that an arbitrary unassociated peer can exploit it.
Original configured compilation input `components/wpa_supplicant/esp_supplicant/src/esp_wpa_main.c:146155` still contains:
```c
uint8_t *wpa_ap_get_wpa_ie(uint8_t *ie_len)
{
/* ... existing hostapd checks ... */
*ie_len = hapd->wpa_auth->wpa_ie_len;
return hapd->wpa_auth->wpa_ie;
}
```
`esp_wifi_driver.h:133` has the matching `uint8_t *len` callback signature; `esp_wpa_main.c:489496` registers it under `CONFIG_ESP_WIFI_SOFTAP_SUPPORT`. This is an interface/provenance marker for the old bundle, **not the source location of the opaque management-encryption defect**.
### Exact binary-bundle provenance
The official fix changes **both** callback signatures to `size_t *` and replaces `components/esp_wifi/lib` with **`a4e903fe43bf09a95022f9802db43d39740ccc0b`**. A C-only signature edit is not an adequate or ABI-safe implementation of this correction.
A local `git rev-parse` in the packaged Wi-Fi submodule failed because its Git metadata is not usable. Instead, calculated Git blob SHA-1 values directly from all seven installed `esp32s3/*.a` files and compared them with official content metadata:
- [IDF v5.5 submodule pointer](https://api.github.com/repos/espressif/esp-idf/contents/components/esp_wifi/lib?ref=v5.5): **`8a1b7bbc00e895d040c5c9a6fb9d1db2bbfc7958`**.
- [Baseline ESP32-S3 archive listing](https://api.github.com/repos/espressif/esp32-wifi-lib/contents/esp32s3?ref=8a1b7bbc00e895d040c5c9a6fb9d1db2bbfc7958): **all seven local blob IDs match** (`core`, `espnow`, `mesh`, `net80211`, `pp`, `smartconfig`, `wapi`).
- [Corrected bundle listing](https://api.github.com/repos/espressif/esp32-wifi-lib/contents/esp32s3?ref=a4e903fe43bf09a95022f9802db43d39740ccc0b): all seven differ from installed.
- Installed `libnet80211.a`: Git blob `6ee20bec142638dc0a442b9642de9fa62db6ddd0`, SHA-256 **`dcb524dda8563976cf1a97d4398c7b06cf024b7ef9a5f07788e84c88792d8071`**; corrected bundle's Git blob is `8ce8a2a6a9a753e31508bd609bf0a0d1d44c332f`.
This confirms the installed stock-baseline bundle, not a hidden same-version binary replacement carrying the published fix. It does not independently reverse-engineer the affected routine or establish a reliable remote attack.
**Parent action:** investigate a maintained IDF/vendor bundle carrying the complete correction, with matching supplicant ABI and all existing source-pinned overrides rebased and revalidated. Do not replace one archive or change only the callback type. Preserve USB/UART0 recovery and broker isolation. Target regression should cover PMF-capable WPA2/WPA3 SoftAP association, repeated authentication/reassociation with keys already installed, APSTA transitions and loaded service recovery. These tests were **not performed**. No blind recommendation to weaken PMF or disable recovery AP policy is made.
## Additional IDF release candidates screened
These are supplemental to, not replacements for, the existing detailed IDF review.
| Release candidate / fetched evidence | Actual-use disposition |
| --- | --- |
| 5.5.1 DHCP **client** option/HW-ID length, [bcd56c74 patch](https://github.com/espressif/esp-idf/commit/bcd56c74.patch), contained commit `1ad41e589ff7296c484ba64f206f2278a09dab37` | Original `lwip_default_hooks.c:262269,293300` retains old checks; generated option length **68**, vendor class ID disabled. Client path is used, but current hostname is fixed `CONFIG_LWIP_LOCAL_HOSTNAME="espressif"` and no application `esp_netif_set_hostname()` call exists. Inspected `dhcp_select()`/`dhcp_discover()` build bounded fixed options, short hostname and six-byte MAC, leaving space in 68 bytes; no current long-option trigger established. mDNS suffix is **not** the DHCP hostname. Revisit/backport before longer hostnames or extra options. Distinct from already fixed DHCP **server** CVE-2026-45160. |
| 5.5.2 `esp_timer_dump()` overflow, [a511f3be patch](https://github.com/espressif/esp-idf/commit/a511f3be.patch), contained commit `ec71bd189f6ef42aff2daa99f35d98611bb9faa3` | Original `esp_timer.c:613,616` retains old line-size constants 90/46 (fix 103/47) and profiling branch's incorrect size addition. No application dump call; timer profiling absent. **Not-current application path**, not a globally patched timer library or a ban on ordinary timers. |
| 5.5.3 HTTP **client** Digest OOB read, [6d0a7a01 patch](https://github.com/espressif/esp-idf/commit/6d0a7a01.patch), contained commit `bbcc13be8b38283547883bfee4920331bea80cd1` | Original `esp_http_client/lib/http_auth.c:131132` uses fixed-length `memcmp` on server algorithm string; no application HTTP-client/Digest consumer. **Not-current-path**; the HTTPS server's cookie login is not this function. |
| 5.5.3 AES DMA/PSRAM cache ordering, [3f10cdab patch](https://github.com/espressif/esp-idf/commit/3f10cdab.patch), contained commits `525ef3a2eab239e30cd4cc62f6d23b61743fe4c1`, `0742f3fce3a1779c63ec286342f6d9799e12822f` | Original `esp_aes_dma_core.c:11211128` invalidates after DMA; file is a configured compilation input. S3 has `SOC_PSRAM_DMA_CAPABLE=1`, so **target name alone does not exclude it**. Generated `CONFIG_MBEDTLS_HARDWARE_AES` absent; `esp_config.h:150153` therefore undefines `MBEDTLS_AES_ALT`, and no application `esp_aes_*` call was found. No current application mbedTLS hardware-AES path established. Direct SDK/opaque-radio consumers and their destination buffers were not exhaustively traced: retain this as a **residual SDK applicability question**, not global exclusion or a verified current HTTPS corruption finding. |
| Bluetooth, provisioning, JPEG, TREL/OpenThread, other-chip secure-boot/flash-encryption release fixes | Bluetooth/JPEG/provisioning exclusions above apply; project is not an OpenThread/TREL application. Physical security and other-chip configuration changes do not establish an S3 network attack. These release categories were screened, not their entire implementations audited. |
## Managed USB applicability
Published esp-usb records are [GHSA-gp8r-qjfr-gqfv](https://github.com/espressif/esp-usb/security/advisories/GHSA-gp8r-qjfr-gqfv) (HID host close double-free race), [GHSA-2pm2-62mr-c9x7](https://github.com/espressif/esp-usb/security/advisories/GHSA-2pm2-62mr-c9x7) (HID host descriptor UAF), and [GHSA-g65h-9ggq-9827](https://github.com/espressif/esp-usb/security/advisories/GHSA-g65h-9ggq-9827) (UVC host descriptor-printing stack overflow). Official affected components are `usb_host_hid` through 1.0.4 and `usb_host_uvc` through 2.3.1, **not similarly numbered esp_tinyusb releases**.
`src/usb_cdc_transport.c:763781` calls `TINYUSB_DEFAULT_CONFIG(device_event_callback)`, `tinyusb_driver_install(&usb_config)`, and `tinyusb_cdcacm_init(&cdc_config)` for `TINYUSB_CDC_ACM_0`, using the S3 internal full-speed PHY. Generated settings enable one CDC, 1,024-byte RX/TX buffers, 512-byte CDC endpoint buffer, no HID/MIDI/vendor instances, no DFU/network class. `esp_tinyusb/include/tusb_config.h:88` enables device mode and maps class counts at lines 192201. Filtered compilation database contains **zero** HID-host, UVC-host, or TinyUSB `src/host` entries. Thus all three published host advisories are **not-current-path**, without relying on “USB requires physical access” as an exclusion.
Post-pin wrapper changelog 2.3.0 describes MTP, IDF6 MSC compatibility and power-management/light-sleep integration, not a newly identified CDC memory-safety fix. MSC/MTP is not used; `CONFIG_PM_ENABLE` absent. Upstream TinyUSB's newest published 0.21.0 notes include EP0 OUT copy clamping, HID descriptor bounds and MSC callback-size caps. These are baseline-release fixes rather than evidence that a later release must be backported. Spot-check: installed `src/device/usbd.c:881,920` limits `data_len` to `wLength` and clamps transferred bytes to remaining control data. SHA-256 **`38de54351d81878e6543f2b022d0c39f45d28c8f0888491796398dec45e727be`**. This is not a complete USB control-request audit or proof every upstream fix is in the fork.
## mDNS post-pin fixes
mDNS is network-reachable when STA discovery starts: `src/wifi_manager.c:117` calls the application service, whose `src/mdns_service.c:154160` calls `mdns_init()`, `mdns_hostname_set()` and `mdns_instance_name_set()`. It is **not globally disabled**. The generated profile enables only predefined STA, maximum one interface/service, and has no `CONFIG_MDNS_ENABLE_BROWSE`. No application `mdns_service_add*`, `mdns_query_*` or `mdns_browse_*` calls were found.
1. **1.13.0 duplicate DNS-SD question expansion:** fetched [c56c725dd5e77c83c1a0683fce6dd3521e05cea2](https://github.com/espressif/esp-protocols/commit/c56c725d.patch). Upstream test explicitly calls repeated `_services._dns-sd._udp.local` PTR questions “heap amplification”: each expands the registered service list. Installed `mdns_receive.c:711716` lacks the new `if (parsed_packet->discovery) continue;` guard and allocates inside `while (a)` after `a = mdns_priv_get_services()`. **Primitive unpatched, no current amplification trigger:** no services registered; `mdns_responder.c:3845` zeroes the server and `:102104` returns its initially empty service list. A hostname/instance label is not a service registration, and `MAX_SERVICES=1` is a capacity, not one active service. Reassess before advertising `_https`, `_ssh`, or any DNS-SD service; the guard is a sensible narrow defense-in-depth candidate, not a demonstrated current responder DoS fix.
2. **1.13.1 staged-IP NUL termination:** fetched [b370a9abc29390b4adc612d251cc2dbbb325ff6f](https://github.com/espressif/esp-protocols/commit/b370a9ab.patch). Adds `new_staged_ip->hostname[MDNS_NAME_BUF_LEN - 1] = '\0';`; commit describes a GCC truncation warning. The installed receiver does **not** contain `rx_staged_ip_add()`; browsing is also disabled. Do not assert an installed unterminated-buffer vulnerability simply from version ordering or the release-note title. No current-path defect established.
Installed receiver SHA-256: **`9ada93eb2f89080a28bf2a849871e054032f06889c40397555de527af0e3d7bd`**. Earlier mDNS release notes mention parser/browse hardening, but this slice did not re-audit every pre-pin fix or equate the absence of a GHSA with a safe parser.
## LED strip and offline browser assets
**LED:** `src/status_led.c:1536` fixes `max_leds=1`, WS2812/GRB, RMT, `with_dma=false`; `:7277` sets pixel index zero then refreshes. No peer-controlled strip allocation or index is supplied. No later release/security correction was identified in the declared official channels; no blanket driver safety claim.
**Browser integration:** `src/web_ui.c:15071519` creates a terminal with `allowProposedApi:false`, loads only `FitAddon`, and `:15521554` does the same for admin. `:1531` feeds received bytes to `target.write(new Uint8Array(data), ...)`. Serial-device output is not automatically trustworthy just because a browser session authenticated; escape-sequence rendering remains a real boundary.
Post-pin 6.0.0 candidate dispositions:
- [PR5020 patch](https://github.com/xtermjs/xterm.js/pull/5020.patch), “Escape Unsafe HTML Characters in addon-serialize,” changes **addon-serialize HTML output**, not the core terminal renderer. That addon and `serializeAsHTML()` integration are absent. **Not-current-path**, not an assertion that all xterm 5.5 HTML rendering was repaired.
- [PR5355 patch](https://github.com/xtermjs/xterm.js/pull/5355.patch), “Add note about reverse tabnapping,” is **documentation-only in addon-web-links typings**, warning about iframe/custom-handler behavior. The addon is not loaded. Do not report this as a core security fix missing from 5.5.0.
- Core OSC 8 links exist even without addon-web-links. Checked [5.5.0 upstream provider](https://raw.githubusercontent.com/xtermjs/xterm.js/5.5.0/src/browser/OscLinkProvider.ts) and targeted matching snippets in hash-verified local JS: absent `allowNonHttpProtocols`, protocols are limited to `http:`/`https:`; default activation confirms navigation and sets `newWindow.opener=null` before assigning location. Application supplies no custom `linkHandler`. This rebuts an automatic arbitrary-protocol/tabnapping finding from these release notes, not all phishing/social-engineering risks or browser implementation issues.
- Other release-note changes include OSC parsing/correctness, readonly behavior for disabled input, selection/renderer fixes, and new clipboard/progress functionality. Only fit is installed; search, image, serialize, web-links, ligatures, clipboard and WebGL addons are not supplied. Build/demo updates to Express, ws, webpack, axios, etc. are not automatically firmware/browser runtime dependencies. No additional current security trigger was established from that release-note screen.
A future xterm upgrade would need deliberate addon compatibility, offline asset provenance/regeneration and browser regression review; no asset or version change is authorized by this document.
## Validation and handoff limits
Performed: official bounded advisory/release fetches; all 22 IDF index-entry accounting; targeted source/config/caller and configured-input inspection; managed manifest checks; two local web-asset hash checks; seven Wi-Fi archive Git-blob comparisons; source snippet/hash capture. Final checks passed for document whitespace/final newline, six local links, dated scope, and twelve additional IDF plus three USB advisory IDs. Document-scoped `git diff --check` also passed (the new untracked file was explicitly checked by Python). Final Git status showed concurrent changes in release-notice/validation/icon-provenance files outside this ownership scope; none were edited or reverted here. No firmware build, host regression execution, browser test, USB transaction, radio packet test, fault injection, upload, monitor, erase, or target resource measurement was performed.
**Parent summary:** carry forward the Wi-Fi bundle correction investigation and the residual direct-SDK AES-DMA applicability question. Do not describe either as implemented, or the AES question as an established current-path vulnerability. Preserve prior detailed wolf/IDF review authority and all existing mitigations. The omitted official IDF index pages and named managed/browser publication channels now have a finite dated coverage record; full release-database contents, unpublished issues, opaque implementation analysis and hardware acceptance remain outside the completed evidence. Re-run a bounded publication check before release or when dependencies, enabled features, callers, or trust boundaries change.
+207
View File
@@ -0,0 +1,207 @@
# Dependency license inventory — Phase 9D
## Implementation addendum — updated 2026-09-18
**Missing generated modification/date notices: resolved; current count is nine C sources plus one header.** `tools/security_overrides.py` prepends the baseline project modification notice dated **2026-09-15**, identifying the editable generator and warning against editing derived copies. The wolfSSH `internal.c`, added `ssh.c` and generated `wolfssh/internal.h` also receive a prominent **2026-09-16** ordering-profile/provenance notice. Exact original hashes and exact-once edits remain required; upstream notices are retained. The header is a PUBLIC forced build input, not a C source. Both new mbedTLS copies retain their original Mbed TLS Contributors copyright and **`Apache-2.0 OR GPL-2.0-or-later`** SPDX header; the Apache-2.0 selection in this inventory is unchanged. This resolves the narrow notice-generation finding, **not release packaging or license clearance**.
Offline notice assembly tooling is implemented; actual firmware/device/browser delivery, radio-blob corresponding-source/exception questions, preferred-source packaging, wolfSSH license-package discrepancy, manual bitmap/mockup provenance limits and release-specific runtime/bootloader review remain open; [exact retained SVG provenance](icon_provenance.md) is resolved. [Offline notice assembly](release_packaging.md) is implemented by [tools/release_notices.py](../tools/release_notices.py): 75 mandatory hash/size-pinned inputs (nine IDF/toolchain notice entries semantically rebased for the candidate; the other 66 unchanged), deterministic bounded outputs, fail-closed preflight and no overwrite/fetch/build/device access. Supplied agent evidence: **36 notice fixture tests PASS; two actual bundles verified deterministic, each 77 files / 4,433,930 bytes**. This includes retained SVGs and [exact icon provenance](icon_provenance.md), not corresponding-source delivery or legal clearance. These are agent measurements, not a parent rebuild or reassembly in this documentation turn. Include `cmake/wolf_crypto_policy.{cmake,h}` and the ordering inputs described below with the reproducible build/source package. The IDF 5.5.3 migration is now integrated in the isolated validated candidate; this documentation update performs no upgrade, asset regeneration or device operation. Root pins select platform 6.13.0 / framework 3.50503.0 / both toolchains 14.2.0+20251107; see [integration and build/closure limits](idf_candidate_integration.md). The detailed 5.5.0 SDK/runtime inventory below remains historical, not a newly exhaustive 5.5.3 license audit.
## Historical inventory baseline — 2026-09-15
The original audit scope and artifact counts below describe the audit-time snapshot; generated-source status and the release checklist are updated explicitly where noted. Original research evidence is retained, not a claim that all artifacts were rebuilt or re-audited here.
Local, bounded audit: **2026-09-15**. This is an engineering inventory and release checklist, not legal advice, a legal compatibility opinion, an upstream currency check, or exhaustive file-by-file coverage. No network requests, dependency changes, asset regeneration, firmware build, tests, or device operations were performed. Hardware validation remains deferred for **Phase 9 as a whole**.
## Evidence and counting boundaries
- Project license: [root LICENSE](../LICENSE), [README license declaration](../README.md#license), and representative `src/` SPDX headers establish **`GPL-3.0-only`**, not “or later.” Dependencies keep their own notices and grants.
- Resolution: [application manifest](../src/idf_component.yml), [dependencies.lock](../dependencies.lock), [platformio.ini](../platformio.ini), all six installed managed manifests and top-level license files.
- **7 lock entries = 6 managed packages + IDF**. Managed packages comprise **5 direct + 1 transitive**. IDF is also a direct requirement. wolfSSH uses wolfSSL in this integration, although its resolved manifest declares no dependency edge; both are explicitly pinned by the application.
- Platform: `platformio/espressif32@6.12.0`; installed SDK package `framework-espidf` **3.50500.0**, SDK **5.5.0**, compiler package `toolchain-xtensa-esp-elf` **14.2.0+20241119**. Installed package metadata, not merely requested ranges, was checked.
- Existing `.pio/build/esp32-s3-devkitc-1-n16r8/project_description.json` names **113 nonempty configured components**. Its adjacent `esp32_serial_swiss_army_knife.map` has **68 distinct archive basenames** in the initial “Archive member included” region, before “Discarded input sections.” These are build-selection evidence, **not** 113 independently audited packages or proof all selected bytes survive section garbage collection. These pre-existing artifacts were not rebuilt or certified fresh.
- Below: **6 managed rows, 18 SDK/runtime inventory groups, 2 browser packages, and 1 icon collection containing 2 SVGs**. Groups are not a complete SBOM and must not be summed into a unique package count (e.g. bundled libraries are inside IDF).
- [Web provenance](../web_assets/SOURCES.md): all **8 recorded SHA-256 values** matched (3 JS/CSS, 2 license files, 3 gzip artifacts). Two SVG IDs and the attribution in `src/local_status_ui.c:158161` were inspected for provenance only. Logo ownership is the project's recorded claim, not independently established authorship.
- Initial Git status was clean. An unrelated untracked `docs/security_operations.md` appeared during this audit and was left unread and untouched. This audit creates only this file.
### Local path notation
Repository paths below are relative to the repository root. Installed paths are expressed using these **audit-time roots**, not environment variables that must already be set:
- `SDK/` = `/home/mscholz/.platformio/packages/framework-espidf/`
- `TC/` = `/home/mscholz/.platformio/packages/toolchain-xtensa-esp-elf/`
- `BUILD/` = `.pio/build/esp32-s3-devkitc-1-n16r8/`
`SDK/package.json` identifies <https://github.com/espressif/esp-idf>; `SDK/version.txt` says `5.5.0`. `SDK/LICENSE` is Apache-2.0. **There is no `SDK/COPYRIGHT` in this installed package**, and its `README.md` contains no license/copyright section. Do not assume the root Apache text covers every bundled library or is a complete attribution list. Component license files and source notice headers supply important exceptions.
## Managed components: complete lock-level inventory
All rows have a checked `managed_components/<directory>/idf_component.yml`. Versions below match the lock and installed manifests. Exact component hashes remain in `dependencies.lock`; a manifest-only assertion is not a cryptographic revalidation of all installed package contents.
| Package / resolved version | Relationship | Local license evidence | SPDX interpretation / disposition |
| --- | --- | --- | --- |
| `espressif/esp_tinyusb` **2.2.1** | Direct; requires TinyUSB | `managed_components/espressif__esp_tinyusb/LICENSE` | `Apache-2.0`; retain notices and license. |
| `espressif/led_strip` **3.0.3** | Direct | `managed_components/espressif__led_strip/LICENSE` | `Apache-2.0`. |
| `espressif/mdns` **1.12.0** | Direct; requested `^1.8.2` | `managed_components/espressif__mdns/LICENSE` | `Apache-2.0`; inventory resolved 1.12.0, not minimum 1.8.2. |
| `espressif/tinyusb` **0.21.0~1** | Transitive through esp_tinyusb | `managed_components/espressif__tinyusb/LICENSE` | `MIT`, copyright 20122026 hathach. Nested `hw/bsp/espressif/components/led_strip/LICENSE` is Apache-2.0: it is not a seventh lock package. Retain nested notices if distributing the full package. |
| `wolfssl/wolfssl` **5.8.2~1** | Direct | `managed_components/wolfssl__wolfssl/LICENSE.txt`, `README.md`, `wolfssl/version.h` | `GPL-3.0-or-later` open-source option; GPLv3 can be selected with this project. The commercial alternative is not needed merely because the application is GPLv3-only. |
| `wolfssl/wolfssh` **1.4.20** | Direct | `managed_components/wolfssl__wolfssh/LICENSE.txt`, `README.md`, `src/internal.c` opening notice, `wolfssh/version.h` | **Packaging discrepancy:** LICENSE/README describe *wolfSSL/wolfCrypt*, GPLv2-or-later, and include GPLv2 text; inspected wolfSSH headers explicitly grant **`GPL-3.0-or-later`**. Preserve both evidence sets; select GPLv3 for this integration, and seek upstream packaging clarification before release. Do not label wolfSSH GPL-2.0-only or silently rewrite vendor files. |
Provenance references (not fetched): installed manifests point to [esp-usb](https://github.com/espressif/esp-usb), [esp-protocols](https://github.com/espressif/esp-protocols), [Espressif TinyUSB](https://github.com/espressif/tinyusb), [wolfSSL](https://github.com/wolfSSL/wolfssl), and [wolfSSH](https://github.com/wolfSSL/wolfssh). Installed repository metadata pins esp_tinyusb commit `8e779566ef71d43928cbf7e125e8eb54bab3f542`, mDNS `db06b19b7be729c163d346f62ec0eba01047b7f1`, and TinyUSB `7049c58a0e895acc92c6407574b05b5536eddfc8`. Use the LED strip manifest's repository metadata rather than infer a repository from its package name.
## SDK core, bundled libraries, and compiler runtime
“Selected” means archive-selection evidence in the existing application map, not a full object/license attribution trace. Unversioned rows mean **the installed IDF 5.5.0 snapshot**, not an invented upstream release. Header versions do not identify every Espressif patch; preserve the resolved SDK package itself.
| Group / version evidence | Checked local license / notice paths | SPDX / scope and action |
| --- | --- | --- |
| IDF core and Espressif drivers **5.5.0** | `SDK/LICENSE`, `SDK/package.json`, `SDK/version.txt`; original notices retained in the SDK overlays below | `Apache-2.0` baseline, not an umbrella claim over bundled code. Core archives selected. Include attribution and relevant notices, including bootloader distribution inputs. |
| FreeRTOS **10.5.1**, Espressif port | `SDK/components/freertos/FreeRTOS-Kernel/LICENSE.md`; version in `include/freertos/task.h` under that kernel directory | `MIT`; selected. License text does not substitute for copyright notices in individual files. |
| lwIP **2.2.0 development**, Espressif fork | `SDK/components/lwip/lwip/COPYING`; `src/include/lwip/init.h` under that directory has `LWIP_RC_DEVELOPMENT` | `BSD-3-Clause`; selected. Preserve Swedish Institute of Computer Science attribution; do not call it stock release 2.2.0. |
| Mbed TLS **3.6.3** | `SDK/components/mbedtls/mbedtls/LICENSE`; `include/mbedtls/build_info.h` under that directory | `Apache-2.0 OR GPL-2.0-or-later`; choose Apache-2.0 here. `libmbedtls.a`, `libmbedcrypto.a`, `libmbedx509.a` selected. Framework has a separate `framework/LICENSE` when shipping the full source tree. |
| HTTP parser **2.7.0** | `SDK/components/http_parser/LICENSE.txt`, `http_parser.h` in that component | `MIT`; selected. Preserve NGINX/Igor Sysoev and Joyent/Node attribution in the actual text. |
| argtable3, IDF snapshot | `SDK/components/console/argtable3/LICENSE` | **Aggregate**, including `BSD-3-Clause`, `BSD-2-Clause`, and `TCL` terms. It includes Stewart Heitmann, NetBSD getopt, Tcl, C Hash Table, and Better String notices. Do not reduce the entire file to one BSD label. `libconsole.a` selected; sublibrary/object attribution not exhaustively traced. |
| linenoise, IDF snapshot | `SDK/components/console/linenoise/LICENSE` | `BSD-2-Clause`, Salvatore Sanfilippo and Pieter Noordhuis; console selected. |
| TLSF allocator, IDF snapshot | `SDK/components/heap/tlsf/include/tlsf.h`, `SDK/components/heap/tlsf/tlsf_block_functions.h` | `BSD-3-Clause` SPDX headers, Matthew Conte 20062016; heap selected. No standalone TLSF license text appeared in the historical inventory; offline assembly now retains a scoped full TLSF rendering and provenance. Final object coverage/delivery still require review, not a generic BSD text with another author's name. |
| Xtensa HAL, IDF snapshot | `SDK/components/xtensa/include/xtensa/hal.h` opening notice | `MIT`-style full permission notice, Cadence 19992015; `libxt_hal.a`/`libxtensa.a` selected. Header evidence is not an audit of every HAL object or ROM implementation. |
| Espressif prebuilt radio libraries, IDF snapshot | `SDK/components/esp_wifi/lib/LICENSE`, `SDK/components/esp_phy/lib/LICENSE`, `SDK/components/esp_coex/lib/LICENSE` | All three files are byte-identical to `SDK/LICENSE` (`Apache-2.0`). Map selects Wi-Fi `libcore.a`, `libespnow.a`, `libmesh.a`, `libnet80211.a`, `libpp.a`, and PHY `libbtbb.a`, `libphy.a`. `libesp_coex.a` is selected but that does not prove `libcoexist.a` was selected. **Corresponding-source/system-library question remains open**, despite permissive binary redistribution terms. |
| Newlib **4.3.0** and IDF libc integration | `TC/xtensa-esp-elf/include/_newlib_version.h`; `TC/share/licenses/newlib/COPYING.NEWLIB`; `SDK/components/newlib/COPYING.NEWLIB` | Mixed per-file licenses; **no single SPDX expression established**. Toolchain notice file has 1,293 lines vs SDK copy's 952; preserve the actual runtime package's notices, not just the older SDK summary. `libc.a` and `libnewlib.a` selected. Target-specific LGPL/GPL sections for other architectures/Linux are not proof they apply to ESP32-S3; trace selected objects before making that claim. |
| GCC runtime / libstdc++ **14.2.0**, package **14.2.0+20241119** | `TC/share/licenses/gcc/COPYING.RUNTIME`, `TC/share/licenses/gcc/gcc/COPYING3`; `TC/xtensa-esp-elf/include/c++/14.2.0/xtensa-esp-elf/esp32s3/bits/c++config.h` | Header explicitly supports `GPL-3.0-or-later WITH GCC-exception-3.1`; `libgcc.a`/`libstdc++.a` selected. Check exception eligibility and per-object terms; preserve exception and GPL notices. Package-level `GPL-2.0-or-later` metadata is not a runtime license inventory. `COPYING3.LIB` is LGPL text, **not** the GCC runtime exception. |
| cJSON **1.7.18** | `SDK/components/json/cJSON/LICENSE`, `cJSON.h` there | `MIT`; configured SDK component, no `libjson.a` selection in the inspected initial map region. Retain when distributing the SDK source; not asserted live in this image. |
| FatFs **R0.15 w/patch2** | `SDK/components/fatfs/src/ff.c` opening full notice | `LicenseRef-FatFs-local-notice` (descriptive local identifier, not an official SPDX ID): source-retention permission/disclaimer, ChaN 2022. Configured, no `libfatfs.a` selection observed. |
| SPIFFS, IDF snapshot | `SDK/components/spiffs/spiffs/LICENSE` | `MIT`, Peter Andersson; configured, no `libspiffs.a` selection observed. |
| protobuf-c, IDF snapshot | `SDK/components/protobuf-c/protobuf-c/LICENSE` | `BSD-2-Clause`; configured, no `libprotobuf-c.a` selection observed. |
| ESP-MQTT, IDF snapshot | `SDK/components/mqtt/esp-mqtt/LICENSE` | `Apache-2.0`; configured, no `libmqtt.a` selection observed. |
The eighteenth group is **wpa_supplicant**, recorded separately because its license requires reading two files: `SDK/components/wpa_supplicant/COPYING` redirects to `SDK/components/wpa_supplicant/README`, whose full grant is **`BSD-3-Clause`**. The historical GPLv2 option was retired in 2012 according to COPYING; do not flag a GPLv2-only conflict. `libwpa_supplicant.a` is selected; an independent upstream version was not established (use the IDF snapshot).
The installed license-path inventory also found OpenThread, NimBLE/Bluetooth, micro-ecc, Unity/CMock, and tool/test-specific licenses and NOTICE files. They were not promoted to fully reviewed linked dependencies. A full SDK/source or toolchain redistribution must preserve their applicable texts too. No inference that all of these are absent from every bootloader, ROM, or future build is made. The bootloader map, every bundled file, and every selected runtime object were not audited. Newlib, argtable3, radio blobs, ROM attribution, and nested package contents remain aggregation boundaries needing release-specific review.
## Checked-in assets and existing notice sufficiency
| Asset | Version / local evidence | Finding |
| --- | --- | --- |
| `@xterm/xterm` | **5.5.0**; `web_assets/xterm.js`, `xterm.css`, `xterm.LICENSE`; [SOURCES.md](../web_assets/SOURCES.md) provides exact npm URLs/hashes | `MIT`. Full notice retained in repository; CSS also retains a notice. Minified JS has no `copyright`, `permission is hereby`, or `license` marker. |
| `@xterm/addon-fit` | **0.10.0**; `web_assets/addon-fit.js`, `addon-fit.LICENSE`; same provenance document | `MIT`. Full notice retained in repository; minified JS lacks those notice markers. |
| Pictogrammers Material Design Icons | Official **7.4.47**, immutable commit `9e04201d4557e729822fb57f62a316c3dea1d4a8`; [exact evidence](icon_provenance.md) | Retained SVGs are byte-identical to upstream; metadata credits **Google (USB)** and **Simran (Wi-Fi)**. Apache-2.0 package evidence, full license text and a pinned-tree check finding no NOTICE are retained. Manual firmware bitmap derivation and the distinct Wi-Fi mockup path remain unresolved; source identity is not complete derivative or release clearance. |
| Project logo | `web_assets/logo.png`, derived from `images/logo.png` per SOURCES.md | Project-owned according to provenance document; no independent third-party license assigned. Generator's MIT banner must not be treated as proof that the logo was separately relicensed MIT. |
Historical 2026-09-15 `git ls-files '*LICENSE*' '*COPYING*' '*NOTICE*'` found **4 tracked license files**: root GPLv3, two web MIT texts, and the icon summary. That historical count predates the retained upstream icon evidence and full Apache text now included in the 75-input catalog. Neither repository retention nor assembly proves recipient delivery or release compliance.
`web_assets/generate_embedded_assets.py` embeds three gzip files and the logo, **not either MIT license file**. Its generated-source banner points back to provenance/licenses; it is not the upstream MIT notice itself. This proves the embedding path does not carry the separate notice texts, not that every possible product-delivery channel lacks notices. Coordinate a release notice bundle and a way for recipients of the browser assets to obtain the notices (for example a bundled/served third-party license resource); verify the actual delivery path. Do not assume repository-only notices accompany a standalone firmware/device or a downloaded JS response. No assets or web endpoints were changed in this audit.
Preferred-source availability is a separate issue from retaining MIT notices. For a GPL-covered combined release, preserve exact upstream preferred-form browser sources and build inputs where required; the checked-in minified JS distributions and gzip/C arrays are not automatically the preferred form for modification. Source-map references alone do not provide that source. Whether independent browser packages are mere aggregation should be assessed for the actual distribution, not assumed either way.
## GPLv3 distribution and build-overlay obligations
The inspected MIT, BSD and Apache-2.0 grants provide generally GPLv3-compatible routes; the project's `GPL-3.0-only` choice can use wolfSSL/wolfSSH's version-3 alternatives. This is **not** permission to remove upstream notices or declare all files GPL-only. No commercial wolfSSL/wolfSSH license is recommended on the evidence here. Commercial arrangements would be a separate decision if a planned distribution cannot satisfy the available open-source terms, not a cure for unrelated project/SDK obligations.
For conveyance, review GPLv3 §§1, 46, 7 and 10 in [LICENSE](../LICENSE):
1. Supply required license/copyright/warranty notices and use a §6-compliant corresponding-source delivery method for binaries. A dependency name, registry URL, generic upstream source link, or this inventory alone is not corresponding source or a compliant written offer.
2. Preserve exact application source, lock/manifests, relevant SDK and managed source, configuration/build/link inputs, asset preferred sources and generation scripts. Include the scripts controlling compilation/installation. Generic unmodified build tools may fall outside corresponding source, but record precise versions needed for reproduction; if distributing those tools themselves, satisfy their separate licenses.
3. The Phase 9C9D firmware compiles **pinned originals plus project edits**. Include `tools/security_overrides.py`, `cmake/security_overrides.cmake`, root CMake integration, `cmake/wolf_crypto_policy.{cmake,h}`, the complete `tools/wolfssh_order/` inputs, original hash-matching C/header sources, and the means to reproduce all nine C copies and the PUBLIC forced header. A stock IDF/wolfSSH tarball alone omits the compiled modifications. Generated copies alone are not a substitute for the preferred editable patch/generator inputs.
4. **Resolved; updated 2026-09-18:** all nine generated C files plus one header below retain upstream notices and carry explicit project modification/date notices from the generator (baseline 2026-09-15, plus 2026-09-16 ordering notices on wolfSSH outputs). The historical four-file audit found the notice missing; this narrow defect is repaired. GPLv3 §5(a) calls for a relevant date, and Apache-2.0 §4(b) requires prominent change notices. Continue including notices and reproducible generator inputs in distribution; do not hand-edit generated copies or vendor sources.
5. If distributing a GPLv3 “User Product,” assess and provide required Installation Information under §6. Do not publish device passwords, private keys, Wi-Fi material, account verifiers, tickets, NVS images, or real credential backups as build/source artifacts. Installation compliance should use appropriate documented procedures, not routine secret disclosure. Secure boot was not enabled or altered here.
6. **Prebuilt radio libraries are the principal unresolved compatibility/source risk.** Apache-2.0 permits binary redistribution, but does not itself satisfy GPL corresponding-source obligations for a combined firmware. Establish whether required preferred source is obtainable, or whether a defensible GPL System Library/other exception applies to the actual linked artifacts. Static linkage is not automatically “mere aggregation.” Do not declare distribution cleared or definitely prohibited from this bounded audit. Escalate this before a public binary/device release.
Current generated paths and retained licenses (nine C sources plus one header). Here `BUILD/` denotes the validated candidate build, `.pio/idf-candidate-5.5.3/app-validated/.pio/build/esp32-s3-devkitc-1-n16r8/`, rather than the historical audit root:
- `BUILD/security_overrides/https_server/https_server.c` — Espressif Apache-2.0.
- `BUILD/security_overrides/httpd_parse/httpd_parse.c` — Espressif Apache-2.0.
- `BUILD/security_overrides/httpd_ws/httpd_ws.c` — Espressif Apache-2.0; five signed fixed-header receive-size comparisons, with generated modification/rebase notices.
- `BUILD/security_overrides/esp_tls_mbedtls/esp_tls_mbedtls.c` — Espressif Apache-2.0.
- `BUILD/security_overrides/wolfssh_internal/internal.c` — wolfSSH GPL-3.0-or-later header.
- `BUILD/security_overrides/wolfssh_ssh/ssh.c` — wolfSSH GPL-3.0-or-later header.
- `BUILD/security_overrides/wolfssh_include/wolfssh/internal.h` — wolfSSH GPL-3.0-or-later header; ABI overlay propagated BEFORE PUBLIC and as a PUBLIC forced include, not a C compilation unit.
- `BUILD/security_overrides/dhcpserver/dhcpserver.c` — retained Espressif Apache-2.0 header.
- `BUILD/security_overrides/mbedtls_ssl_tls/ssl_tls.c` — retained Mbed TLS Contributors, `Apache-2.0 OR GPL-2.0-or-later`.
- `BUILD/security_overrides/mbedtls_x509_create/x509_create.c` — retained Mbed TLS Contributors, `Apache-2.0 OR GPL-2.0-or-later`.
### Archived ordering patches and license context
[Ordering provenance](../tools/wolfssh_order/README.md) documents the restricted CVE-2025-14942 correction, not a complete upstream backport or upgrade. [`delta.json`](../tools/wolfssh_order/delta.json) is the preferred editable, executable consolidated delta; archived upstream mail patches are provenance/prerequisite evidence, not a statement that all their hunks compile into the firmware. [`provenance.json`](../tools/wolfssh_order/provenance.json) retains the original URLs and every full embedded commit ID. Archived bytes fetched 2026-09-16 are pinned as follows; future PR responses may differ:
| Archive under `tools/wolfssh_order/` | SHA-256 |
| --- | --- |
| `793.patch` | `66aa999521800371f97b340db2bff162be4e8ffab4b4b953f9d32b8e33f56cea` |
| `819.patch` | `8e63c2b24679a7d831f7dba12e412d2f8a7fdc391fc4e8fd33f029487110219b` |
| `840.patch` | `23e25873cb4dfa36063357111019e0960dfc008a68cc29d6e2d5c37bde47ec94` |
| `855.patch` | `b94393df9528f66f6df1aba94015331fee3110c9ea2007a0eff369d8887b4b29` |
| `921.patch` | `f6c88598d7f3c94d92c31cecc4ef1e6d779b49f9eff6556ac1c57426d272d504` |
Preserve upstream mail authorship, commit metadata and applicable file notices with these archives. They contain upstream wolfSSH code/context, not newly project-authored material automatically relicensed by the generator's `GPL-3.0-only` SPDX line. The compiled wolfSSH C/header derivatives retain the upstream **GPL-3.0-or-later** grants; select GPLv3 for this integration. Patch hashes prove identity, not license permission or full archive-level clearance. Retain the upstream license texts and package discrepancy evidence, and review applicable per-file terms for any archived but unapplied example/test/workflow content when assembling the source package. No standalone blanket license grant for every patch byte is inferred from mail-patch format. Source/notice delivery and release packaging remain open.
For Apache-2.0 components, retain required attribution and any applicable upstream NOTICE contents under §4(d), **if present**; do not fabricate NOTICE text or assert every Apache package must have a NOTICE file. For MIT/BSD components, distribute the actual copyright, permission/conditions and disclaimer, not merely an SPDX name. Preserve mixed notices such as argtable3 and Newlib rather than replacing them with generic templates.
## Actionable release work (not performed)
Notice assembly is implemented; the remaining release actions below have not been completed.
| Priority | Action / completion evidence |
| --- | --- |
| Release blocker to resolve | Document a defensible corresponding-source/exception basis for the **selected radio blobs** and other source-unavailable linked content. This is an unresolved question, not a legal verdict. |
| Assembly tooling implemented; delivery pending | [Offline tool and procedure](release_packaging.md) assemble 75 pinned inputs, retaining GPL/Apache/MIT/BSD texts, mixed SDK/toolchain Newlib and argtable3 notices, GCC exception and a scoped full TLSF rendering. Supplied agent fixture suite: 36 PASS; two actual bundles independently verified deterministic, each 77 files / 4,433,930 bytes. Validate final firmware/device/browser recipient delivery; assembly is not corresponding-source delivery or legal clearance. |
| Before distributing icon derivatives | [Exact retained SVG provenance](icon_provenance.md) is resolved at official 7.4.47; full Apache-2.0 text and attribution are retained, with no NOTICE found in the pinned distribution. Preserve manual bitmap preferred sources and resolve the distinct mockup Wi-Fi path/derivation limits. Recipient delivery remains open; do not invent a NOTICE or rasterizer. |
| Resolved in generator; retain in distribution | Prominent modification notices cover **nine C sources plus one header** (baseline **2026-09-15**, wolfSSH ordering **2026-09-16**), with original notices retained. Release packaging must still include them, archived patches/provenance, consolidated delta and reproducible build inputs. |
| Upstream clarification | Record/resolve wolfSSH 1.4.20 package LICENSE/README vs source-header mismatch without editing the managed package in place. GPLv3 is supported by the inspected headers and is also within the package prose's “or later” wording. |
| Release packaging verification | Archive exact corresponding source/build inputs and required asset preferred sources; validate source delivery/offer and any User Product installation information. Recheck bootloader, runtime objects and aggregation boundaries against the final release map. |
## Minimal offline repeat check
Run from the repository root against an already-resolved local environment. This only reads manifest/license/provenance metadata; it does **not** invoke PlatformIO, install dependencies, regenerate assets, read configuration secrets, or validate legal compliance. It requires Python 3 and PyYAML (the latter is normally available in the component-manager environment). A missing prerequisite must be reported, not automatically downloaded. Adjust the two package roots if auditing another machine.
```sh
python3 - <<'PY'
from pathlib import Path
import hashlib
import json
import re
import yaml
sdk = Path('/home/mscholz/.platformio/packages/framework-espidf')
tc = Path('/home/mscholz/.platformio/packages/toolchain-xtensa-esp-elf')
expected = {
'espressif/esp_tinyusb': ('2.2.1', 'LICENSE'),
'espressif/led_strip': ('3.0.3', 'LICENSE'),
'espressif/mdns': ('1.12.0', 'LICENSE'),
'espressif/tinyusb': ('0.21.0~1', 'LICENSE'),
'wolfssl/wolfssh': ('1.4.20', 'LICENSE.txt'),
'wolfssl/wolfssl': ('5.8.2~1', 'LICENSE.txt'),
}
lock = yaml.safe_load(Path('dependencies.lock').read_text())
assert set(lock['dependencies']) == set(expected) | {'idf'}
assert str(lock['dependencies']['idf']['version']) == '5.5.0'
assert sdk.joinpath('version.txt').read_text().strip() == '5.5.0'
assert json.loads(sdk.joinpath('package.json').read_text())['version'] == '3.50500.0'
assert json.loads(tc.joinpath('package.json').read_text())['version'] == '14.2.0+20241119'
for name, (version, license_name) in expected.items():
directory = Path('managed_components') / name.replace('/', '__')
manifest = yaml.safe_load((directory / 'idf_component.yml').read_text())
assert str(lock['dependencies'][name]['version']) == version
assert str(manifest['version']) == version
license_path = directory / license_name
assert license_path.stat().st_size > 0
print(name, version, license_path,
hashlib.sha256(license_path.read_bytes()).hexdigest())
for name, digest in re.findall(r'\| `([^`]+)` \| `([a-f0-9]{64})`',
Path('web_assets/SOURCES.md').read_text()):
assert hashlib.sha256((Path('web_assets') / name).read_bytes()).hexdigest() == digest
for path in [Path('LICENSE'), Path('third_party/material-design-icons/LICENSE'),
sdk / 'LICENSE', sdk / 'components/wpa_supplicant/README',
sdk / 'components/console/argtable3/LICENSE',
sdk / 'components/newlib/COPYING.NEWLIB',
tc / 'share/licenses/newlib/COPYING.NEWLIB',
tc / 'share/licenses/gcc/COPYING.RUNTIME']:
assert path.stat().st_size > 0
print(path, hashlib.sha256(path.read_bytes()).hexdigest())
for name, digest in {
'dependencies.lock': '2d1c62093cafdb54cd209813c526c35e2e470b62cc6f5b0cde9a040569662726',
'src/idf_component.yml': '7fe8cfd25db36dd23907cd1aa7645f3d5b5f34f7ed587cd2599690b5ffbda053',
'platformio.ini': 'b0139667b13c0e254b5ffa67df6cb323f4554a3657fb100330067d6110d57518',
}.items():
assert hashlib.sha256(Path(name).read_bytes()).hexdigest() == digest, name
print('PASS: scoped manifest versions, notice presence, and recorded hashes')
PY
```
This is a **drift tripwire**, not a full component-content verifier or complete license scanner. On changes, inspect new notices and nested components, trace final application/bootloader maps, revisit the build overlays and regenerate a release inventory deliberately. Do not simply repin expected values. Network/upstream authentication, advisory review, legal aggregation analysis, complete notice assembly and hardware validation remain outside this check.
+7 -30
View File
@@ -1,12 +1,12 @@
# Bounded ordinary HTTPS idle retention
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.
Current owner/SDK contract. The user accepted idle cleanup as working; [overall acceptance](web_administration_acceptance.md) records current status and evidence limits. No individual unreported soak, fault or reserve result is inferred.
## Evidence and scope
User-authorized response to the admission capture: post-TLS occupancy **6/6, ordinary4 / serial2 / admin0**, ordinary connection ages **5074 seconds**; admin ticket returned in **14 ms**, no subsequent TLS/upgrade observed, ticket unconsumed. These ages measure connection lifetime, not time since the last request. They support investigating retained ordinary sockets, not claiming every captured socket was idle. Installed HTTPD excludes its listening fd from `select` while full with LRU disabled. New connections can therefore wait before TLS or upgrade even when ticket issuance was fast.
This policy releases **expired idle ordinary HTTPS connections**, not arbitrary ordinary requests to make room. Six sockets, 24 URI handlers, LRU disabled, existing receive/send timeouts (one second), TLS handshake timeout (five seconds), all task stacks/queue depths and browser code remain unchanged. No new task. UART0 recovery, independent USB access, serial broker ownership, tickets, authentication and both WebSocket transports are unchanged.
This policy releases **expired idle ordinary HTTPS connections**, not arbitrary ordinary requests to make room. The policy uses the existing six sockets, LRU-disabled operation, one-second receive/send and five-second TLS-handshake timeouts without expanding task stacks or queue capacities. Current overall handler capacity is 39. No new task. UART0 recovery, independent USB access, serial broker ownership, tickets, authentication and both WebSocket transports are unchanged.
## Usage and timeout semantics
@@ -37,7 +37,7 @@ All SDK references below are under `~/.platformio/packages/framework-espidf/comp
- `esp_http_server/include/esp_http_server.h` has no global synchronous post-request-cleanup hook. Its event notifications are not such a hook: `esp_http_server_dispatch_event` posts to the event loop. `HTTP_SERVER_EVENT_SENT_DATA` is emitted by `httpd_resp_send` and per `httpd_resp_send_chunk`, **before** handler return/body purge/cleanup. Send/receive overrides belong to HTTPS and do not expose a safe completion hook. URI matching and error handlers likewise cannot supply an all-route post-cleanup boundary.
- `esp_https_server/src/https_server.c`: successful `httpd_ssl_open` stores the transport context and installs TLS send/recv/pending functions before synchronous `HTTPD_SSL_USER_CB_SESS_CREATE`. The application callback invalidates any old row for that fd even if the TLS pointer, socket-slot address and counter value were reused. If fd lookup unexpectedly fails, all observations reset conservatively. The close callback remains the diagnostic observer; SDK destruction remains intact. `httpd_ssl_pending` calls `esp_tls_get_bytes_avail` without consuming data; errors can post an existing SDK error event.
- `httpd_sess_trigger_close` resolves fd to a raw reusable `sock_db *`, then queues `httpd_sess_close`. Its zero-counter/LRU guard does not prove the same connection still occupies that slot. **Not used by this policy.** Direct owner shutdown retains the slot until SDK read cleanup and has no deferred fd/pointer argument that could later close a replacement.
- `httpd_queue_work` uses `cs_send_to_ctrl_sock` / `sendto` in `src/util/ctrl_sock.c`. The actual generated config leaves `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` undefined/off, selecting the nonblocking queue mode. The idle initializer explicitly rejects builds with that blocking option on. Successful `httpd_stop` waits for `THREAD_STOPPED`, frees HTTPD and ends possible old callback execution; failed stop is not a retirement boundary.
- `httpd_queue_work` uses `cs_send_to_ctrl_sock` / `sendto` in `esp_http_server/src/util/ctrl_sock.c`. The actual generated config leaves `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` undefined/off, selecting the nonblocking queue mode. The idle initializer explicitly rejects builds with that blocking option on. Successful `httpd_stop` waits for `THREAD_STOPPED`, frees HTTPD and ends possible old callback execution; failed stop is not a retirement boundary.
## Ownership and bounded storage
@@ -49,36 +49,13 @@ Detach first prevents submissions, then waits at most one second for any submitt
`src/web_httpd_adapter.{c,h}` defines the six-row bound and 15-second policy and performs the version-pinned owner sweep. `src/CMakeLists.txt` adds only the new module. Diagnostics remains unchanged internally; server composes idle identity reset followed by existing diagnostic publication.
## Validation and resource accounting
## Regression and resource limits
Commands executed successfully:
`tests/web_httpd_idle/run.py` exercises production lifecycle/sweep paths, installed SDK request cleanup, host socketpair/readiness/shutdown/fd reuse and deterministic TLS/timer/queue doubles. `tests/web_admin_transport/server_lifecycle.py` checks server composition. Historical host validation passed; these references do not claim a new run or real target scheduler/TLS timing.
```sh
python3 tests/web_httpd_idle/run.py
python3 tests/web_admin_transport/server_lifecycle.py
python3 tests/web_diagnostics/run.py
python3 tests/web_cookie_auth/run.py --accounts
python3 tests/web_cookie_auth/run.py --admin
python3 tests/web_cookie_auth/run.py --settings
python3 tests/web_cookie_auth/run.py --serial-settings
python3 tests/web_admin_transport/run.py --tickets
python3 tests/web_session_store/run.py --serial
python3 tests/web_ui_session/run.py
python3 tests/admin_console_boundary/run.py
python3 tests/admin_console_boundary/accounts.py
python3 tests/admin_console_boundary/lifecycle.py
python3 tests/admin_ssh_policy/run.py
pio run
git diff --check
```
Bounded storage: six observation rows (144 bytes), one persistent timer (installed non-profiled layout 32 bytes before allocator overhead), lifecycle/generation flags and one reserved probe. SDK control UDP/mailbox allocation is transient and separate. No per-connection payload/task/stack/socket growth. Actual HTTPD/timer margins and runtime overhead/reserve floors require target measurement, not host sizes. See [latest firmware resources](web_administration_acceptance.md#latest-firmware-evidence).
- Idle suite: **18 lifecycle groups plus SDK/source guards**. Compiles complete production lifecycle module and production sweep, with installed SDK `httpd_sess_process`/`httpd_req_delete`. Uses real host `socketpair`, readiness, shutdown, EOF and fd reuse; TLS, parsing/response callbacks, timer/queue scheduling and session deletion are deterministic doubles. Tests cover full slots, never-used post-TLS idle, five-second polling, pending/readable/error input, slow synchronous handlers/purge, failed requests, upgrade/async/closing-WS exemption, exact fd/TLS/slot/counter reuse, queue failures, early callback return, submit-fence timeout, failed stop, discarded/stale generations, accepted-but-lost UDP work, diagnostic-independent enforcement and timer initialization/restart/exhaustion. No real TLS/ESP-IDF scheduler or target timing claim.
- Server lifecycle **18 groups**, diagnostics **12+1**, admin transport **25** / tickets **12**, browser **68 plus renderer/CSP**, and the listed auth/store/canonical console/account suites pass. The cookie harness required one missing `<stdint.h>` include after its private-layout double was expanded; fixed, all listed variants rerun successfully.
- Production build: **57.55 seconds**, **99,316 B RAM / 1,708,981 B flash**, **+160 / +1,384 B** versus the recorded diagnostic baseline (99,156 / 1,707,597). Final no-op confirmation `pio run` also passed in **3.13 seconds** with identical sizes.
- Target object symbols: rows **144 B**; module static symbols **167 B total before linker placement/alignment**. Linked RAM delta need not equal the sum of new symbols. One persistent internal-memory ESP timer allocation: installed non-profiled layout **32 B before allocator overhead**. Control UDP packet/mailbox transient allocation is SDK-owned, not included in static RAM. No task/stack/socket/queue-capacity increase.
- Target disassembly local frames (exclude callees): sweep **80 B**, idle work/timer **32/32 B**, prepare **64 B**, attach/detach/stopped **32 B each**, TLS idle callback **48 B**, composed TLS callback **32 B**. Actual HTTPD/timer stack margins, heap minima, TLS churn/latency and runtime reserves are **unmeasured/unapproved**.
## Target checklist — not performed
## Regression procedure — not execution evidence
1. Start with diagnostic capture disabled: establish two serial WebSockets and ordinary HTTPS fetches. Verify idle ordinary sockets disappear after the observation window while both serial clients/lease remain unchanged. Enable capture only as needed to compare close/open occupancy; ages remain connection ages, not idle timestamps.
2. Reproduce the original ordinary4/serial2 full-slot case; wait beyond the idle window, then issue/open admin. Record client ticket/TLS/upgrade timings and occupancy without recording tickets/cookies. Verify no repeat reload loop is needed for already-old idle saturation. Separately test fresh saturation and acknowledge the existing 15-second browser timeout limit.
+117
View File
@@ -0,0 +1,117 @@
# Exact USB and Wi-Fi icon provenance
Reviewed 2026-09-16. This record resolves the identity of the **two retained
preferred-form SVG sources at Material Design Icons 7.4.47**, not all project
artwork, historical authorship of manual edits, or release compliance.
No firmware, mockup, web asset, or generated asset was changed or regenerated.
## Official pinned source and licenses
Official SVG distribution: <https://github.com/Templarian/MaterialDesign-SVG>.
Its upstream README identifies this as the production SVG distribution and
points to the main MaterialDesign repository for issues. The main repository
URL with tag `v7.4.47` returned 404; it is not used as version evidence.
- Distribution tag: `v7.4.47`.
- Annotated tag object: `5edde266e281d26a03dcfa89fb651183cbab0f2e`.
- Peeled commit: `9e04201d4557e729822fb57f62a316c3dea1d4a8`.
- Upstream `package.json`: `@mdi/svg`, version `7.4.47`, license `Apache-2.0`.
- Tag timestamp: `2023-12-26T23:33:26Z`; GitHub reports it **unsigned**.
HTTPS retrieval and content pins are evidence, not signature authentication.
Unmodified upstream SVGs, LICENSE, README, package metadata, complete `meta.json`,
and GitHub tag/tree responses are retained in
[`third_party/material-design-icons/upstream-7.4.47/`](../third_party/material-design-icons/upstream-7.4.47/).
`downloads.json` records exact retrieval URLs, sizes and SHA-256 hashes. Raw
source URLs use the immutable commit, not a moving branch or tag. API responses
are retained as fetched; future API formatting is not assumed stable.
The full metadata/tree files intentionally preserve the evidence behind the
per-icon selections and absence check, rather than only project-written claims.
The existing `third_party/material-design-icons/LICENSE` is byte-identical to
the pinned upstream Pictogrammers Free License summary and remains unchanged.
It distinguishes icon Apache-2.0/respective grants from code MIT terms. The
package declares Apache-2.0; the two metadata entries contain authors but no
per-icon alternate grant. This is the scoped upstream licensing evidence for
these two SVGs, not a blanket conclusion about every icon in the collection.
`upstream-7.4.47/Apache-2.0.txt` supplies the **full Apache-2.0 text**, fetched
from <https://www.apache.org/licenses/LICENSE-2.0.txt> (SHA-256
`cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30`).
It is a separately sourced license text, not a file falsely attributed to the
upstream icon tree. The retained non-truncated recursive tree has no path
containing `notice` (case-insensitive); no upstream NOTICE was found in this
pinned distribution and none is fabricated here. This does not audit unrelated
repositories or assert that a NOTICE is mandatory for every Apache work.
| Icon | Metadata attribution | Stable icon ID | First-version metadata | SVG SHA-256 |
| --- | --- | --- | --- | --- |
| `usb` | Google | `25033E0B-3AD4-414D-9972-559F2690FC1D` | `1.5.54` | `c9918e9a983fbd788378ca4c524e7a07a0d5eedcaeff73d19814e6f6ae221f22` |
| `wifi-strength-4` | Simran | `41B86B22-7245-4A97-9BAA-3E9EBD44CEB0` | `2.3.50` | `89d14daf863076b0f73c76d913212875f2e9bcddaaf49c9e5d1825e0b2dc2d5f` |
The metadata `version` fields describe icon introduction versions, **not** a
contradiction of package 7.4.47. Matching these sources to that release does not
prove they originated in that release or were originally downloaded from it.
## Exact comparison to project derivatives
1. **Retained SVGs:** `third_party/material-design-icons/usb.svg` (403 bytes)
and `wifi-strength-4.svg` (219 bytes) are each byte-for-byte identical to the
pinned upstream originals, including every path command, coordinate, arc
flag and viewBox. Each has one path, `viewBox="0 0 24 24"`, and no transform
on the root or path. No coordinate rounding, normalization or visual-match
inference was used. The files remain unchanged.
2. **USB mockup:** `docs/phase7c_icon_mockup.svg` symbol `usb` has exactly the
upstream `d` string and 24-by-24 viewBox, without symbol/path transforms.
Its two direct use definitions are `(x,y,width,height)=(150,4,30,30)` inside
reusable `status`, and `(8,72,30,30)` on the broker page. These apply uniform
scale `30/24 = 1.25` and the stated translations. Status is instantiated in
page groups translated by `(62,98)`, `(592,98)`, `(62,438)`, `(592,438)`;
the broker use is in `(62,438)`. Thus status USB geometry maps as
`(X,Y)=(pageX+150+1.25*x, pageY+4+1.25*y)` and broker USB as
`(X,Y)=(62+8+1.25*x,438+72+1.25*y)`. CSS adds presentation fill/stroke;
this is path geometry equivalence, not pixel/rendering equivalence.
3. **Wi-Fi mockup:** symbol `wifi` in that same mockup uses a different,
segmented path starting `M1 8q11-9 22 0`. It is **not** an exact copy of
`wifi-strength-4` (which starts `M12,3C7.79,3`). No exact affine derivation
or upstream provenance for that distinct mockup path is established here.
4. **Actual firmware:** `src/local_status_ui.c` explicitly calls the USB mask
hand-rasterized and the Wi-Fi masks compact segmented adaptations. The exact
editable row bytes are:
```text
s_icon_usb: 18 3c 18 5a 3e 18 3c 18
s_icon_wifi_full: 7e 81 3c 42 18 24 00 18
s_icon_wifi_three: 00 00 3c 42 18 24 00 18
s_icon_wifi_two: 00 00 00 00 18 24 00 18
s_icon_wifi_one: 00 00 00 00 00 00 00 18
```
These manual 8-by-8 masks are not SVG coordinates or exact scaled vector
paths. No deterministic rasterizer, sampling/threshold rule or edit history
establishes an exact upstream-to-mask transform. Their declared design
provenance is supported by the now-verified retained sources, but **exact
mechanical derivation of the firmware masks remains unresolved**. Preserve
the C arrays as the preferred editable form of those manual modifications,
together with both original SVGs; do not substitute an invented generator.
The existing source comment is the adaptation notice. This dated record
documents the inspected modifications; it does not invent their edit date.
## Offline checks and release boundary
`python3 tests/release_notices/run.py` verifies the pinned source bytes and Git
blob identities, version/author metadata, catalog inclusion, exact SVG geometry,
USB mockup placement/ancestry, distinct Wi-Fi mockup path, and current manual
mask bytes. Negative checks reject coordinate and transform changes. Tests do
not regenerate artwork or claim raster equivalence.
The notice catalog retains all previous inputs and adds the two existing SVGs,
these source originals, licensing/metadata evidence and this record. The bundle
is still **not corresponding source or release clearance**. Firmware C arrays
and the full mockup remain in the project, not copied into this bounded notice
bundle; include them in a separately reviewed corresponding-source delivery
where required. Radio-blob/source-exception questions, wolfSSH packaging,
recipient notice/source delivery and other release gates remain open.
Older global inventory/packaging/memory documents describe the pre-verification
state and counts. They were intentionally not edited in this scoped task;
this record and the current catalog provide the narrower updated evidence.
+190
View File
@@ -0,0 +1,190 @@
# IDF 5.5.3 uncommitted rebase review
Review date: **2026-09-18**. Baseline Git HEAD: `cdc4d4a8df02c2c44180b9b6d17bf6104c9fbcd2`.
**Current disposition: existing protected corrections are semantically retained; the initial stale compiled WS input and missing receive-error regression blockers are resolved by the supplied final fresh-build/24-suite evidence below.** No evidence found that a protected correction should be removed as superseded. This is a bounded rebase review, not advisory recertification, device acceptance or release clearance.
Initial reviewer-owned change: **this document only**. Existing working changes were preserved. No SDK installation, firmware build, upload, erase, asset regeneration or production edit was performed. A concurrent change to `third_party/release-notices/inputs.json` appeared during review; it was left untouched and was not reviewed in that initial pass. The later supplied notice review semantically rebased nine entries, retaining the other 66 unchanged; this does not turn the initial review into a catalog audit.
## Final follow-up — 2026-09-18
Supplied final parent command, run from the repository root:
```sh
CCACHE_DISABLE=1 python3 -B tools/validate_phase9.py --build-dir .pio/idf-candidate-5.5.3/app-validated/.pio/build/esp32-s3-devkitc-1-n16r8 --idf-path .pio/idf-candidate-5.5.3/core/packages/framework-espidf --platformio-core-dir .pio/idf-candidate-5.5.3/core --interop --web-performance
```
**PASS 24/24.** This selects the actual fresh isolated build, SDK and toolchain core; it is not a default-root-build execution. The separate fresh `.pio/idf-candidate-5.5.3/app-validated` build **PASS** reports **95,552 B linked RAM / 1,749,493 B flash**, versus historical **94,340 / 1,768,901 B** (**+1,212 B RAM / 19,408 B flash**). The default parent `pio run` timed out after **200 seconds during installation, before compilation**; no normal root build PASS is claimed.
Pre/post source equality: **3,237 files**, SHA-256 **`3a1af78c15cfdd02da1055a8957b4086f9018862b7aa1c7c52fd2401a1a0a031`**. Actual generated-input registration covers **nine C sources plus one forced header**. WS receive tests exercise generated code: **982 cases / 10 mutation checks**, including the five signed `sizeof` corrections. The stale web-cookie fixture asserting IDF 5.5.0 was corrected, not bypassed. Historical stale-WS compilation/coverage failures in the [rebase review](idf_553_rebase_review.md) are resolved by this final snapshot, not hidden or retroactively called passes.
The [fix-bearing Wi-Fi bundle](wifi_security_update_plan.md) is integrated in the candidate, with unchanged PMF/WPA3; radio-hardware vulnerability closure and full target/resource/recovery gates remain pending. Nine notice catalog entries were semantically rebased, the other 66 unchanged (75 total); supplied notice evidence is **36 fixtures PASS**, two actual deterministic bundles each **77 files / 4,433,930 bytes**. Archive pins and source equality do not prove complete immutable root/ancillary/Python dependency closure, legal clearance or Phase 9 acceptance. This documentation update records supplied parent evidence; it did not rerun these builds, suites, bundles or hardware tests.
## 1. Historical review inputs, not assumed SDK paths
Paths below are relative to the repository except the explicitly absolute shared SDK:
| Role | Observed path / identity |
| --- | --- |
| Old SDK | `/home/mscholz/.platformio/packages/framework-espidf`, package **3.50500.0** / IDF 5.5.0 |
| Candidate archive | `.pio/idf-candidate-5.5.3/archives/framework-espidf-3.50503.0.tar.gz` |
| Candidate installed SDK | `.pio/idf-candidate-5.5.3/core/packages/framework-espidf`, package **3.50503.0** / IDF 5.5.3 |
| Isolated application | `.pio/idf-candidate-5.5.3/app` |
| Existing candidate build | `.pio/idf-candidate-5.5.3/app/.pio/build/esp32-s3-devkitc-1-n16r8` |
| Candidate compiler in compile database | `.pio/idf-candidate-5.5.3/core/packages/toolchain-xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc` |
There is no literal `.pio/idf-candidate` directory. The shared SDK is still **old**, not an installed production 5.5.3 SDK. There was no root `.pio/build/esp32-s3-devkitc-1-n16r8` build to validate. Tests defaulting to the shared SDK must explicitly select the candidate.
Recomputed **size and SHA-256 of all four complete archives** against `tools/idf_candidate/artifacts.json`: all matched (platform 6.13.0, framework 3.50503.0, Xtensa and RISC-V 14.2.0+20251107). Framework archive SHA-256: `8353f6fd5030dd7e662500891428fad15d46efd7e4b718cab2fe6bfb9e7f13fc`. Current `platformio.ini` selects that version pairing. Registry version pins in the root configuration are not themselves enforcement of these archive hashes; the isolated application uses local archive URLs.
Loaded the protected-entry registry from **Git HEAD**, verified every old original against its HEAD hash, then compared it with the working registry and actual candidate sources. Every working original hash and exact edit application passed. Also compared candidate protected IDF originals and `esp_httpd_priv.h` directly with members of the hash-verified framework archive: byte-identical. This separates old baseline, downloaded package, installed candidate and generated compilation inputs.
## 2. Historical blockers — resolved in final follow-up
### B1 — Historical candidate firmware does not contain the new WS correction
The staged `app/tools/security_overrides.py` lacks `Entry("httpd_ws", ...)`; the working generator includes it. The staged test runner also differs from the working web-performance runner. The candidate compile database compiles:
```
.../core/packages/framework-espidf/components/esp_http_server/src/httpd_ws.c
```
not `security_overrides/httpd_ws/httpd_ws.c`. The map includes `libesp_http_server.a(httpd_ws.c.o)`. The working SDK override test with this actual build fails:
```
AssertionError: ('httpd_ws', [])
```
All eight previously protected generated C files and the wolfSSH forced header independently match the current generator's bytes. Their compile paths are present; representative wolfSSH and application commands retain the joined forced-header and crypto-policy includes. This is a **specific stale ninth-C-source build**, not evidence that all replacements are missing.
An ELF/bin/map exists, but this reviewer did not build it. Snapshot identities:
- `firmware.elf`: 15,558,488 bytes; SHA-256 `39d12988d80c44787f1d9587d7ba685c990bfac940ba10180b86e60313b60fad`.
- `firmware.bin`: 1,749,888 bytes; SHA-256 `0477958591e0e21269b21fdd62ba855d507a6dd460107647f4f38739f9264e96`.
- `esp32_serial_swiss_army_knife.map`: SHA-256 `346abf8ce619742edd38274eef6267d6d8e0750fea6b1e2c8ffdcc34e5bc0085`.
**Required:** stage the final reviewed inputs in an ownership-safe isolated workspace, rebuild, and verify all **nine C overrides plus one forced header**, exact owners, original-source absence and generated bytes against that build. Do not attribute the current WS correction to the existing binary.
### B2 — Historical vendor-code tests and missing signed-error behavioral matrix
`tests/web_serial_performance/run.py` reads installed `httpd_ws.c`, not generated `httpd_ws.c`. Its enum-aware receive double is a necessary adaptation, but the suite fails compiling `httpd_ws_get_frame_type` against candidate vendor source:
```
error: comparison of integer expressions of different signedness
[-Werror=sign-compare]
```
The new five-edit override is included in generic generator/CMake fixtures. However, `tests/sdk_security_overrides/run.py` has no dedicated WS receive behavior or mutation suite. The web-performance receive double always supplies a successful first byte and the control-frame receiver is a double; it cannot establish the newly changed real frame-receive error paths.
**Required:** consume verified generated WS code without suppressing the warning or editing vendor files. Exercise actual `httpd_ws_get_frame_type`, `httpd_ws_recv_frame` and, where needed, the real `httpd_recv_with_opt` against bounded IO doubles: negative fail/timeout, EOF, short lengths and success at all five fixed-header reads; split/pending bytes; two- and eight-byte lengths; four-byte mask; automatic control-frame failure/close and no subsequent payload/send on failed framing. Require each removed signed cast to be detected by a negative regression/mutation. Passing generic generation alone is insufficient.
### B3 — Historical documentation gap: final snapshot required
At the initial review, `docs/idf_candidate_integration.md` described preparation only, production untouched and smoke not run, while the worktree already had production configuration/guard changes and an isolated application ELF. The generated modification notice pointed to that preparation document as rebase evidence without per-entry dispositions. The integration record now links the semantic dispositions and supplied final build/test evidence. This review's initial artifact inspection is not retroactively a fresh build pass.
Broader wolf ordering/parser/crypto/authentication, browser admission and full phase validation were **not rerun by this reviewer**. Existing historical 5.5.0 results and a stale candidate build cannot stand in for final-snapshot integration validation. Hardware/radio/resource/recovery and distribution/source obligations remain separate established gates, not reasons to repeat completed broad advisory research.
## 3. Per-entry semantic disposition
All old entries preserve exactly their HEAD edit sequences; changed IDF file hashes reflect the vendor differences below. Exact successful replacement is necessary but was not the only check: old/new source differences and relevant ownership/error paths were inspected, and existing behavioral fixtures were executed against candidate-generated sources.
| Protected entry | Disposition and reason |
| --- | --- |
| `dhcpserver` | **Retain unchanged original and six edits.** Vendor source is byte-identical across these SDKs; bounded option header/body, PAD/END handling and minimum lengths are not supplied by this upgrade. Extracted parser passes 196,623 guard-page/equivalence cases. |
| `mbedtls_ssl_tls` | **Rebase original hash; retain EMS error return.** Vendor changes include handshake-state helpers, dynamic-buffer reset handling, PRF label-length plumbing and exporter support. The `calc_verify` error branch still logs without returning; the local return remains necessary before PRF use. SHA256/SHA384 failures, success, PRF failure, non-EMS and resumption fixtures pass. Dynamic TLS buffers remain rejected. This is not a review of every new exporter/TLS1.3 path. |
| `mbedtls_x509_create` | **Rebase original hash; retain OID allocation check.** Vendor now rejects a prepopulated output list instead of clearing it; it still dereferences an unchecked newly allocated `oid.p`. Local OOM return remains needed. Subject/issuer OOM, cleanup and retry fixtures pass. Project certificate generation sets subject and issuer separately once, consistent with the changed empty-list precondition; do not generalize this to repeated setter calls elsewhere. |
| `https_server` | **Rebase original hash; retain all four edits.** Vendor delta adds hardware ECDSA curve/block configuration, not post-handshake allocation-failure cleanup, copied-key wiping or failed-start secure-context destruction. Local TLS delete, wipe-before-free and restored/null-cleared ownership still apply. Existing allocation/handshake/start/stop matrix passes. Failed stop continues to retain live ownership. |
| `httpd_parse` | **Rebase original hash; retain all six edits.** Vendor delta fixes cookie length reporting on truncation, not scratch ownership. Null initial pointer arithmetic, failed `realloc` ownership and unwiped retired scratch still require local fixes. Allocate/copy/wipe/free, preserve-old-on-failure, nullable parser pointer and final wipe remain; null/grow/shrink/failure/pending-unread fixtures pass. |
| `esp_tls_mbedtls` | **Rebase original hash; retain guards and server-only policy.** Vendor adds dynamic-buffer strategy, hardware ECDSA configuration, RSA-ALT cleanup and error/certificate reporting changes. None supplies the project server TLS1.2 ECDHE-ECDSA AES-GCM allowlist or no-renegotiation policy. Injection remains in server configuration; clients retain their defaults. Server/client isolation, feature and dynamic-buffer guard fixtures pass. |
| `wolfssh_internal` | **Retain original hash and all 53 edits.** Managed source matches HEAD. Ordering delta, restricted no-EXT_INFO profile, password bounds/wiping, parser/name/service/signature/window corrections are unchanged; this IDF migration does not supersede them. The initial semantic review did not rerun full wolf behavior; the final supplied 24/24 run includes the established wolf suites. |
| `wolfssh_ssh` | **Retain original hash and ordering edits.** Generated candidate bytes match working output; exact generated compilation path present. |
| `wolfssh_internal_header` | **Retain original hash and ABI/order overlay.** Generated header bytes match; candidate library and representative application commands retain the forced include and overlay include path. PUBLIC direct/transitive consumer fixture passes. |
| `httpd_ws` (new) | **New candidate-specific correction, not a repin.** IDF 5.5.3 changes fixed header reads from nonblocking `<= 0` checks to blocking `< sizeof(...)`. `int` negative receive results convert to unsigned `size_t`, making the checks false. The five `(int)sizeof(...)` changes correctly restore signed rejection for sizes 1, 2, 8, 4 and 1; they retain vendor blocking reads and do not alter payload framing policy. Behavioral regression and current-build evidence were initially blocked by B1/B2; the final follow-up resolves both with 982 cases / 10 mutation checks and actual generated-source registration. |
No protected correction was demonstrated to be **superseded**. Native pre-handshake support is not a reason to delete the application's admission adapter: it is disabled in the candidate configuration and the application deliberately registers the two upgrade routes as ordinary GET handlers.
## 4. Heap extent and HTTPD private boundary
### Heap: guarded 5.5.3 extent contract is supported
`components/heap/heap_caps.c`, `multi_heap.c`, `multi_heap_platform.h` and `tlsf/tlsf.c` are byte-identical old-to-candidate. The audited chain remains:
1. `heap_caps_get_allocated_size` removes any owner offset, locates the containing heap and subtracts owner metadata from the reported size.
2. Unpoisoned `multi_heap_get_allocated_size` aliases the implementation returning `tlsf_block_size`.
3. TLSF obtains the allocated payload block's rounded size, not a containing-block extent for an arbitrary interior pointer.
Candidate resolved config enables `CONFIG_HEAP_POISONING_DISABLED`, disables task tracking and leaves dynamic mbedTLS buffers off. `ssh_memory` still rejects poisoning and all other IDF versions. Actual candidate-source contract checks and host allocation/free/realloc wipe tests pass, including rounded capacity, retained shrink, growth and allocation failure. No new allocator layout/header mechanism was introduced. Target wipe latency and old-plus-new allocation peaks remain unmeasured.
### HTTPD: relevant layouts retained; receive API changed
The private-header diff adds shared control-message declarations, `HTTPD_RECV_OPT_*`, the enum receive parameter and a direct-LRU-close declaration. It does **not** change the adapter-used `httpd_data`, `httpd_req_aux` or `sock_db` field layouts. The application compiles against candidate headers rather than embedding an old SDK struct definition.
Relevant behavior review:
- Session completion still performs request creation/handling, request deletion/purge, then updates the LRU completion marker. New sessions initialize the counter from the server; the adapter already tracks session identity. All 18 idle lifecycle groups pass against candidate completion/purge sources.
- `httpd_main` still processes control work before connection data. New direct LRU close is conditional; application `lru_purge_enable` remains false.
- Header/scratch parser delta is the cookie reporting fix, not a layout/termination change. Right-aligned pending bytes and application wipe preservation remain applicable.
- Optional URI registration still publishes before `strdup` failure cleanup; the application's allocate-both-before-publication correction remains necessary.
- Vendor URI dispatch adds optional pre-handshake callback support. Candidate config disables it; application ordinary-GET upgrade handlers continue to authorize/admit before manually sending 101.
- SDK WS sending remains separate header/payload sends through session `send_fn`; the bounded combined binary adapter still uses that TLS override and replaces it with reject-only behavior after incomplete output. No raw socket bypass was introduced.
- `httpd_recv_with_opt` changes from Boolean to enum and loops for blocking fixed-header reads; `httpd_recv` uses `NONE`. Async completion now wakes the owner with a control message. Application owner/async exclusion rules remain relevant. The receive double was correctly changed to the enum, and the initial WS receive-error/test-input blockers B1/B2 are now resolved as recorded in the final follow-up.
This supports the narrow guard update; it does not certify arbitrary configurations, async use or every HTTPD API.
## 5. Whole radio bundle identity and actual link inputs
Compared **every regular archive member** under these component trees with the corresponding installed candidate file; all were byte-identical:
| Component | Files compared |
| --- | ---: |
| `esp_wifi` | 163 |
| `esp_phy` | 114 |
| `esp_coex` | 41 |
| `wpa_supplicant` | 301 |
This includes packaged source, headers, CMake inputs and all target/optional archives, not only the seven currently discussed S3 Wi-Fi blobs. It establishes coherent delivery from the one locked framework archive, **not** independent upstream PHY/coexistence Git submodule provenance, vendor signature validation or opaque binary correctness.
S3 Git blob identities recomputed from installed bytes:
| Archive | Git blob SHA-1 |
| --- | --- |
| Wi-Fi `libcore.a` | `9f7b14a8bf6eec64973da8adc65d35b5ba9bee49` |
| Wi-Fi `libespnow.a` | `132b4f67e339ca2081d2add91c14eefa39476ff9` |
| Wi-Fi `libmesh.a` | `2e9dc1c8c7afbf033337b4175032e9b1161e3262` |
| Wi-Fi `libnet80211.a` | `2800d447ec385d33869373696ba8191292647694` |
| Wi-Fi `libpp.a` | `8944bcad7371621045f376cf74c62fde6f368cbb` |
| Wi-Fi `libsmartconfig.a` | `4dc759e25617aa00b9e12887fd092a1d5780a170` |
| Wi-Fi `libwapi.a` | `65655b6feab0572a6e8a1200946d53a21f3f4722` |
| PHY `libphy.a` | `75d3c8756c8e0310066fa9a1e5c24df50e6c04e3` |
| PHY `libbtbb.a` | `e94e3c2b476d8abb2757bcb53519ebcfba92b213` |
| PHY `libbttestmode.a` | `06389c391ea55278df6205b6195b6d8182c7b8be` |
| PHY `librfate.a` | `05dc4b69e02dac8e6c75ab8f9dc49c1d33d66c32` |
| PHY `librftest.a` | `45c6dc240287332c8c8790e84a80475dbfe3a842` |
| Coexistence `libcoexist.a` | `21fb35634fbdd421519bc985072fdc0ab54dd1df` |
The seven Wi-Fi identities agree with the already recorded 5.5.3 **release-point** column in `docs/wifi_security_update_plan.md`; they are not a mixed fix-point/release-point bundle. Candidate `esp_wifi_driver.h` declares `wpa_ap_get_wpa_ie(size_t *len)` and `esp_wpa_main.c` defines `wpa_ap_get_wpa_ie(size_t *ie_len)` and assigns that callback. The compile database uses candidate supplicant sources.
Actual candidate map `LOAD` entries select **all seven Wi-Fi blobs plus `libphy.a` and `libbtbb.a` from the candidate SDK path**, and the candidate-built component wrappers/supplicant archive. No old shared-SDK radio path was observed in those inputs. `libcoexist.a` is delivered and verified but is **not a LOAD input** in this configuration: `esp_coex/CMakeLists.txt` adds it only for software/external coexistence, not merely `CONFIG_ESP_COEX_ENABLED`. Optional RF/test PHY archives likewise are packaged identities, not claimed linked inputs. A map `LOAD` line does not prove all members survive link garbage collection.
The whole-package approach avoids a 5.5.0 C/header/blob transplant. This provides stronger delivery/path evidence than the earlier seven-Wi-Fi-only check, but does not demonstrate runtime PMF/WPA3 behavior, radio stability, target reserves or deployment vulnerability closure.
## 6. Historical validation executed and handoff
Commands run from the repository root:
```sh
CCACHE_DISABLE=1 python3 -B tests/ssh_memory/run.py --idf-path .pio/idf-candidate-5.5.3/core/packages/framework-espidf
IDF_PATH=/home/mscholz/Repos/ESP32_serial_swiss_army_knife/.pio/idf-candidate-5.5.3/core/packages/framework-espidf CCACHE_DISABLE=1 python3 -B tests/web_httpd_idle/run.py
IDF_PATH=/home/mscholz/Repos/ESP32_serial_swiss_army_knife/.pio/idf-candidate-5.5.3/core/packages/framework-espidf CCACHE_DISABLE=1 python3 -B tests/web_serial_performance/run.py
CCACHE_DISABLE=1 python3 -B tests/sdk_security_overrides/run.py --idf-path .pio/idf-candidate-5.5.3/core/packages/framework-espidf --build-dir .pio/idf-candidate-5.5.3/app/.pio/build/esp32-s3-devkitc-1-n16r8
```
Results:
- **PASS:** SSH allocator behavior, six invalid poisoning profiles, explicit disabled profile, seven rejected IDF versions, real candidate extent-source checks and compile with candidate version header.
- **PASS:** all 18 idle lifecycle groups and candidate completion/owner-order guards.
- **FAIL:** web serial performance compile against original WS source, B2.
- **PARTIAL PASS / overall FAIL:** SDK generator, HTTPS/scratch/TLS/DHCP/EMS/X509 behavior and CMake replacement/forced-header/extension fixtures pass; actual build registration rejects missing generated WS input, B1. Do not report this command as passing.
- **PASS:** read-only HEAD/current original-hash and edit comparisons; all four archive identities; protected candidate/archive comparisons; all four radio component trees; candidate compile/map path inspection.
The initial next actions were generated-WS regression coverage, a fresh staged snapshot/build and final-input integration validation. Those actions are now complete in the supplied final follow-up; the historical failures above remain failures of the old snapshot, not current blockers or retroactive passes. No production defect was found in the retained old overrides or reviewed heap/private-layout assumptions. Normal root build completion, immutable full dependency closure, target/radio/resource/recovery and release gates remain open. Preserve PMF/WPA3, broker isolation, native USB independence and UART0 recovery.
+100
View File
@@ -0,0 +1,100 @@
# Official PlatformIO IDF 5.5.3 candidate integration
Initial package preparation: **2026-09-16**, Linux x86_64; integration update: **2026-09-18**. Preparation-only statements below describe the earlier stage. Root `platformio.ini` and reviewed guards/overrides are now migrated; shared-SDK installation is not claimed complete and no device/deployment acceptance is claimed.
## Final integration evidence — 2026-09-18
Supplied final parent command, run from the repository root:
```sh
CCACHE_DISABLE=1 python3 -B tools/validate_phase9.py --build-dir .pio/idf-candidate-5.5.3/app-validated/.pio/build/esp32-s3-devkitc-1-n16r8 --idf-path .pio/idf-candidate-5.5.3/core/packages/framework-espidf --platformio-core-dir .pio/idf-candidate-5.5.3/core --interop --web-performance
```
**PASS 24/24.** This selects the actual fresh isolated build, SDK and toolchain core; it is not a default-root-build execution. The separate fresh `.pio/idf-candidate-5.5.3/app-validated` build **PASS** reports **95,552 B linked RAM / 1,749,493 B flash**, versus historical **94,340 / 1,768,901 B** (**+1,212 B RAM / 19,408 B flash**). The default parent `pio run` timed out after **200 seconds during installation, before compilation**; no normal root build PASS is claimed.
Pre/post source equality: **3,237 files**, SHA-256 **`3a1af78c15cfdd02da1055a8957b4086f9018862b7aa1c7c52fd2401a1a0a031`**. Actual generated-input registration covers **nine C sources plus one forced header**. WS receive tests exercise generated code: **982 cases / 10 mutation checks**, including the five signed `sizeof` corrections. The stale web-cookie fixture asserting IDF 5.5.0 was corrected, not bypassed. Historical stale-WS compilation/coverage failures in the [rebase review](idf_553_rebase_review.md) are resolved by this final snapshot, not hidden or retroactively called passes.
The [fix-bearing Wi-Fi bundle](wifi_security_update_plan.md) is integrated in the candidate, with unchanged PMF/WPA3; radio-hardware vulnerability closure and full target/resource/recovery gates remain pending. Nine notice catalog entries were semantically rebased, the other 66 unchanged (75 total); supplied notice evidence is **36 fixtures PASS**, two actual deterministic bundles each **77 files / 4,433,930 bytes**. Archive pins and source equality do not prove complete immutable root/ancillary/Python dependency closure, legal clearance or Phase 9 acceptance. This documentation update records supplied parent evidence; it did not rerun these builds, suites, bundles or hardware tests.
## Concrete result
**Select official `platformio/espressif32@6.13.0` + `platformio/framework-espidf@3.50503.0` (IDF 5.5.3) + `platformio/toolchain-xtensa-esp-elf@14.2.0+20251107`.** The official adapter also selects `platformio/toolchain-riscv32-esp@14.2.0+20251107` for ESP32-S3 ULP; include it even when evaluating the Xtensa application.
This is an explicitly released supported pairing, not a speculative package override. Exact downloaded archive hashes below make the selected inputs content-pinned; tags/version labels alone are not treated as immutable.
Official channels checked:
- [Platform registry](https://api.registry.platformio.org/v3/packages/platformio/platform/espressif32): latest stable **7.1.3**, published September 11, 2026. [Latest release API](https://api.github.com/repos/platformio/platform-espressif32/releases/latest) agrees (`prerelease=false`). Its [manifest](https://raw.githubusercontent.com/platformio/platform-espressif32/v7.1.3/platform.json) selects `~4.60100.0`, **IDF 6.1**, not IDF 5.5.
- [Framework registry](https://api.registry.platformio.org/v3/packages/platformio/tool/framework-espidf): newest published 5.5 package is **3.50503.0**, published February 18, 2026. Returned 5.5 versions are 3.50503.0, 3.50502.0 and 3.50500.0. No 5.5.4/5.5.5 package appears in that response. Thus 5.5.3 is the **latest available official PlatformIO 5.5 candidate**, not the latest upstream Espressif 5.5 maintenance release. Registry framework metadata labels its tier `community` but its owner is `platformio`; the platform itself is tier `official`.
- [6.13.0 release](https://github.com/platformio/platform-espressif32/releases/tag/v6.13.0), [release API](https://api.github.com/repos/platformio/platform-espressif32/releases/tags/v6.13.0): explicitly adds IDF **5.5.3** and updates IDF toolchains to **14.2.0+20251107**. Stable release, February 26, 2026.
- [6.13.0 manifest](https://raw.githubusercontent.com/platformio/platform-espressif32/v6.13.0/platform.json): framework `~3.50503.0`, Xtensa `14.2.0+20251107`. [Adapter](https://raw.githubusercontent.com/platformio/platform-espressif32/v6.13.0/platform.py) removes the legacy chip-specific Xtensa toolchains for standalone IDF, enables unified Xtensa for S3, and selects the same-date PlatformIO RISC-V package for S3 ULP.
- [Xtensa registry](https://api.registry.platformio.org/v3/packages/platformio/tool/toolchain-xtensa-esp-elf) and [RISC-V registry](https://api.registry.platformio.org/v3/packages/platformio/tool/toolchain-riscv32-esp) both publish the selected Linux x86_64 artifacts.
If the requirement is specifically upstream **5.5.5**, rather than the newest officially delivered 5.5 maintenance release, that requirement remains **blocked on official packaging/support**. Do not substitute 7.1.3 plus an arbitrary 5.5 override or a raw GitHub source archive.
## Actual downloaded identities
All four complete archives were downloaded into `.pio/idf-candidate-5.5.3/archives/` and their **local bytes** passed both registry size and SHA-256 checks. These are measured download checks, not just registry advertisements. No archive was unpacked into the shared PlatformIO SDK. Total compressed size: **992,746,039 bytes**.
| Artifact | Bytes | SHA-256 |
| --- | ---: | --- |
| `espressif32-6.13.0.tar.gz` | 1,009,115 | `5d1032b43828773ba87cf2e509432202c0bfe64f7304b58c9d669f13b116c6e0` |
| `framework-espidf-3.50503.0.tar.gz` | 76,402,966 | `8353f6fd5030dd7e662500891428fad15d46efd7e4b718cab2fe6bfb9e7f13fc` |
| `toolchain-xtensa-esp-elf-linux_x86_64-14.2.0+20251107.tar.gz` | 322,439,270 | `a5de49ce3299b0d9253ab6a423648bc23113db96b34a7cc8e57702cae1bb190e` |
| `toolchain-riscv32-esp-linux_x86_64-14.2.0+20251107.tar.gz` | 592,894,688 | `1af8e233931500b8712079808e4974413d95d3601d03275dff79665c436e9d33` |
Machine-readable registry URLs, artifact URLs, versions, system selectors, sizes and hashes: [`artifacts.json`](../tools/idf_candidate/artifacts.json). No automatic repinning occurs.
The verifier reads members directly from the hash-verified archives without extracting or executing vendor files. Actual checks passed:
- Platform manifest version, framework range and exact Xtensa requirement.
- SDK package version and SDK `tools/tools.json` recommendation `esp-14.2.0_20251107`.
- Fixed `wpa_ap_get_wpa_ie(size_t *len)` callback declaration.
- All seven packaged ESP32-S3 Wi-Fi libraries match the **5.5.3 release-point Git blob identities**, not the fix-point-only bundle, recorded in [the existing Wi-Fi plan](wifi_security_update_plan.md#bundle-identity-baseline-fix-point-release-point-are-different). The plan already establishes release commit `2c211b236707889e8400c4dc5644dd5c4ee071e0` and Wi-Fi submodule `e0befaa593277b4e80726079fbd521b4681754c2`; this task does not repeat fix research.
Whole-archive SHA-256 pins include the delivered PHY/coexistence/source/header contents, preventing changes to those bytes going unnoticed by this verifier. The subsequent [semantic rebase review](idf_553_rebase_review.md#5-whole-radio-bundle-identity-and-actual-link-inputs) compared every regular packaged file in `esp_wifi` (163), `esp_phy` (114), `esp_coex` (41), and `wpa_supplicant` (301) against the installed candidate: all matched. This is complete comparison of those delivered component trees, not merely seven Wi-Fi archives. This is **not** an independent recursive source-to-package audit, vendor signature verification, proof of opaque implementation correctness, or execution of the compiler binaries.
## Reproduce preparation and verification
From the repository root, with Python 3.9+ on Linux x86_64:
```sh
python3 -B tools/idf_candidate/test_prepare.py
python3 -B tools/idf_candidate/prepare.py
```
The second command is offline, verifies all four already-downloaded archives, and makes no installation. Missing or altered inputs fail. Four offline helper tests cover corrupted hash/size, URL restrictions, contract failure, and absent/ambiguous archive members.
On a fresh checkout, download and create the isolated project:
```sh
python3 -B tools/idf_candidate/prepare.py --fetch --prepare
```
Network is restricted by the tool to HTTPS `dl.registry.platformio.org` and `dl.registry.nm1.platformio.org`, including redirects. Approximately 993 MB download space is needed plus substantial unpacked/build space for the later test. `--sdk-only --fetch` obtains/verifies only the platform and SDK. Existing mismatched archives fail rather than being overwritten. A killed download may leave a `.partial` file; inspect/remove that candidate-only partial before retrying. `--prepare` deliberately refuses an existing smoke directory rather than overwriting it. **Preparation already succeeded here**; use offline verification, not a second `--prepare`.
Generated smoke project: `.pio/idf-candidate-5.5.3/smoke/`. Its configuration uses the verified local official platform archive and exact local framework/toolchain archives, with `core_dir` under `.pio/idf-candidate-5.5.3/core/`. Its sources are a separate empty `app_main`; it neither inherits production config nor imports application overrides. Generic official ESP32-S3 board is intentional: this tests package integration, **not** the production N16R8 board or feature configuration.
### Command to test the isolated candidate
After successful verification, from the repository root:
```sh
env -u PLATFORMIO_PACKAGES_DIR -u PLATFORMIO_PLATFORMS_DIR -u PLATFORMIO_BUILD_DIR -u PLATFORMIO_CACHE_DIR -u IDF_PATH -u IDF_TOOLS_PATH -u IDF_PYTHON_ENV_PATH PLATFORMIO_CORE_DIR=/home/mscholz/Repos/ESP32_serial_swiss_army_knife/.pio/idf-candidate-5.5.3/core CCACHE_DISABLE=1 pio run --project-dir .pio/idf-candidate-5.5.3/smoke
```
For another checkout location, replace the absolute `PLATFORMIO_CORE_DIR` accordingly. Run in a normal clean PlatformIO shell, not an activated unrelated IDF environment. The initial preparation task did **not run this smoke invocation**; the later fresh application build PASS is recorded above and does not retroactively claim execution of this exact smoke command. It may download platform ancillary packages and IDF Python dependencies (registry/mirror, PyPI/files.pythonhosted.org and Espressif download endpoints as requested by the adapter); grant those hosts separately as needed. Those ancillary/Python dependencies are not yet a complete frozen build closure. The prepared four-input lock is not advertised as a fully reproducible toolchain environment/SBOM. No upload, monitor or erase command is part of this evaluation.
## Rebase disposition and remaining integration gates
The official package-availability/toolchain mismatch question and bounded application build/host compatibility checks are resolved for the validated candidate. The [per-entry semantic review](idf_553_rebase_review.md#3-per-entry-semantic-disposition) retains every old protected correction (rebasing changed IDF original hashes), retains wolfSSH C/ABI overlays, and adds the ninth C override for five signed WS receive-size comparisons. Nothing was removed as superseded or bypassed by a permissive version guard. Reviewed heap extent/private HTTPD guards now target 5.5.3; generated-input ownership/include order and behavioral suites pass on the final build.
Remaining gates:
1. A successful normal root build: the default attempt stopped during installation, before compilation. Exact root version pins are not enforcement of the downloaded archive hashes.
2. Complete immutable ancillary/Python/tool/managed-component dependency closure; four verified primary archives and source equality are not a full reproducible environment or SBOM.
3. Target/radio/resource/recovery validation in the [Wi-Fi plan](wifi_security_update_plan.md), including the exact trigger and unchanged PMF/WPA3. Candidate integration is not hardware vulnerability closure.
4. Recipient notices, corresponding source, radio-blob legal basis and release-specific runtime/bootloader attribution. Nine notice entries have been semantically rebased and 66 retained unchanged, but assembly is not delivery or legal clearance.
5. Explicit whole-Phase-9 target acceptance. No upload, erase, credential migration, PMF weakening or generated-asset regeneration is part of this documentation update.
+267
View File
@@ -0,0 +1,267 @@
# Focused installed ESP-IDF security applicability review
## Finite applicability completion — 2026-09-16
**Current disposition supersedes the historical recommendations below.** This slice covers exactly the six previously named mbedTLS findings and the disabled/unused-feature entries on the first page of the official IDF advisory index. It is **not an exhaustive CVE inventory, security certification, upgrade approval, or Phase 9 sign-off**. Only this document was edited; no source/configuration/generated-file edits, build, test compilation, or device commands were performed.
### Effective source and configuration evidence
Rechecked installed **ESP-IDF 5.5.0 / mbedTLS 3.6.3**, not just upstream release ranges. Evidence is the existing `.pio/build/esp32-s3-devkitc-1-n16r8/` configuration and compilation database, installed source under `/home/mscholz/.platformio/packages/framework-espidf/`, and the checked-in override renderer. This establishes configured inputs, **not executable freshness or the firmware running on a device**.
- The current registry has **eight C replacements plus one wolfSSH internal-header overlay**. The seven-source count in the September 15 addendum is historical. Read-only `python3 -B` inspection called `verify_version()` and `render_entry()` only for the **six IDF-owned entries**, checked original hashes, and compared rendered bytes against existing generated copies: all six matched. No generator or build was run; wolfSSH bytes were outside this slice.
- Filtered compilation entries select generated `dhcpserver.c`, `ssl_tls.c` and `x509_create.c`, but original `ecdh.c`, `pk_ecc.c`, `pkparse.c`, `ecp_curves.c`, `asn1write.c`, `x509_crt.c`, `ssl_tls12_server.c` and `httpd_ws.c`. None of the six newly classified findings has a project source backport. The mbedTLS entries use `MBEDTLS_CONFIG_FILE="mbedtls/esp_config.h"`; inspected command flags add no alternative user config. `esp_config.h` includes the generated `sdkconfig.h` and maps its ECP/NIST options to the library macros.
- Generated header: `CONFIG_MBEDTLS_ECP_C=1`, `CONFIG_MBEDTLS_ECP_NIST_OPTIM=1`, `CONFIG_MBEDTLS_ECDSA_DETERMINISTIC=1`, TLS 1.2 and client/server support enabled, TLS 1.3 absent. P-521 is enabled (maximum ECP output 66 bytes). Hardware MPI support is enabled, **not** a replacement for all software ECC reduction. Both mbedTLS client/server ticket options are enabled, but **`CONFIG_ESP_TLS_SERVER_SESSION_TICKETS` is absent**. Do not describe tickets as globally compiled out.
- Rechecked generated EMS error branch (`mbedtls_ssl_tls/ssl_tls.c:70837086`) returns before derivation, and generated X.509 OID allocation (`mbedtls_x509_create/x509_create.c:313317`) checks NULL before copying. These fixes are separate from ECDH, PK parsing, basicConstraints and ASN.1 value-state issues.
Additional installed-source SHA-256 evidence (paths below relative to `components/mbedtls/mbedtls/library/`; earlier original hashes remain unchanged):
| Source | SHA-256 |
| --- | --- |
| `ecdh.c` | `85439414902f8421db8921c69a0be771ed69a7c69f0722127298f41a1d330ba8` |
| `pk_ecc.c` | `19ba6ca1251a610a53e3654a7727ba881c358a29d46f4586ab3c7e07c846b5bf` |
| `pkparse.c` | `36c0590c516a1b4ad99bfefc213da1a19a2e27638ee3f267ea3365cc738ecf17` |
| `x509_crt.c` | `799f6993d323d9158bf832967f404e9c63b0d76c0a72023be7a80c42630020fd` |
| `ecp_curves.c` | `29329ed2ecad6e68f615b1b5a3e7c592a0b625c4d1a1090280e305fa9afb51d3` |
| `ssl_tls12_server.c` | `ddf8d37e862d4176d27b928592b1971d2f397f50c4b1fac2443d099c3e9cab99` |
### Disposition matrix
**Addressed** means the specified source correction exists, not target acceptance. **Not-current-path** means this finding's prerequisite is absent in the inspected configuration/callers, not that the library is patched. **Excluded** is a threat-model boundary, not technical immunity. **Unresolved** marks evidence/coverage still absent.
| Finding | Disposition | Current evidence and boundary |
| --- | --- | --- |
| CVE-2026-45160 DHCP; CVE-2026-50581 TLS 1.2 EMS; CVE-2026-34874 X.509 OID OOM | **Addressed, scoped source corrections** | Existing pinned generated inputs match the renderer. Earlier host/build evidence remains attributed below; not rerun. EMS correction is TLS 1.2 only. |
| CVE-2026-35336 ECDH small output buffer | **Not-current-path; primitive unpatched** | `ecdh.c:649659` compares `mpi_size(z)` but writes curve-width bytes. HTTPS TLS premaster storage includes `MBEDTLS_ECP_MAX_BYTES`; no application direct caller supplies a small buffer. See caller qualification below. |
| CVE-2026-50583 zero-length ECC PK parse | **Not-current-path / configuration not affected** | Advisory explicitly excludes built-in ECC in 3.x. `MBEDTLS_ECP_C` is enabled; `pk.h:179182` requires it to be **absent** for `MBEDTLS_PK_USE_PSA_EC_DATA`. Active ECP-backed parser rejects `ilen < 1` before inspecting the point. |
| CVE-2026-49300 basicConstraints | **Not-current-path; parser unpatched** | Both lax parsing behaviors exist. No untrusted certificate/trust-root enrollment or HTTPS client-certificate authentication was found; local identity validation requires CA=false. No current cross-validator CA promotion path established. |
| CVE-2026-54435 ECC optimized reduction side channel | **Excluded attacker model; affected code active** | NIST optimization and deterministic ECDSA are enabled; P-256 identities use mbedTLS. Privileged-local precise execution traces / physical side channels are outside this network-focused review. No constant-time, hardware immunity, or key-extraction-resistance claim. |
| CVE-2026-50586 NewSessionTicket disclosure | **Not-current-path; server tickets disabled, primitive unpatched** | HTTPS defaults `session_tickets=false`, application does not override it; ESP-TLS callback registration is additionally compiled out. mbedTLS ticket machinery itself remains enabled. |
| CVE-2025-48965 stale ASN.1 value length | **Not-current-path; primitive unpatched** | `asn1write.c:412415` still clears pointer without length. Certificate creation uses fresh subject/issuer lists, each a single fixed-format nonempty CN. Exact upstream one-line fix is now resolved below. |
| CVE-2026-45541 WS subprotocol | **Not-current-path; SDK unpatched** | Existing manual-admission/NULL-subprotocol disposition below remains; not reclassified as globally fixed by this slice. |
| Target fault behavior, local side-channel feasibility, running firmware, advisories beyond this finite set/index page | **Unresolved / not evaluated** | No runtime measurements, fault injection, exhaustive inventory or target acceptance supplied by this review. Revisit applicability whenever callers, features or trust enrollment change. |
### Evidence for the six named findings
**CVE-2026-35336:** fetched the official [ECDH advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2026-07-possible-buffer-overflow-ecdh-calc-secret.md) and [source fix `1d71bcc31cb8080b14b0dcbc6bc859c11d622c0f`](https://github.com/Mbed-TLS/mbedtls/commit/1d71bcc31cb8080b14b0dcbc6bc859c11d622c0f.patch). First fixed 3.6.x release: **3.6.7**. Installed vulnerable comparison matches the removed hunk. `ssl_tls12_server.c:37853789` passes `handshake->premaster`; `ssl_misc.h:990` sizes it as `MBEDTLS_PREMASTER_SIZE`, whose union in `ssl.h:691724` includes `_pms_ecdh[MBEDTLS_ECP_MAX_BYTES]`. Although the TLS call's `blen` argument is `MBEDTLS_MPI_MAX_SIZE`, the **actual object**, not merely that argument, is sufficient for every enabled ECDH curve. The advisory explicitly says TLS ECDH is unaffected. Targeted `src/` search found no direct `mbedtls_ecdh_calc_secret()` caller.
Also checked the installed Wi-Fi adapter rather than assuming HTTPS is the only SDK consumer: `wpa_supplicant/esp_supplicant/src/crypto/crypto_mbedtls-ec.c:803` uses the DPP maximum 66; DPP is not enabled in the generated configuration. Its `crypto_ecdh_set_peerkey()` at line 1290 uses `pbits / 8`; the inspected OWE caller in `src/rsn_supp/wpa.c` restricts both offered/received group to **19 (P-256)**, so this does not supply the advisory's non-byte-aligned P-521/floor-size trigger. OWE support **is** compiled in; this is not a blanket audit of its parsing or every SDK ECDH wrapper. Future arbitrary-curve callers must allocate and pass at least `(pbits + 7) / 8`, or use `MBEDTLS_ECP_MAX_BYTES`. A pinned `ecdh.c` backport is defense in depth, not a demonstrated current HTTPS overflow correction.
**CVE-2026-50583:** fetched the official [zero-length ECC public-key advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2026-07-zero-length-ecc-public-key-oob-read.md). It affects driver-only PSA ECC in 3.5.03.6.6, not built-in ECC. Original `pk_ecc.c:204220` has the unchecked `*pub` only under `MBEDTLS_PK_USE_PSA_EC_DATA`; its active `#else` calls `mbedtls_ecp_point_read_binary()`, whose `ecp.c:775777` checks `ilen < 1`. Application PK parsing **does occur**, in `src/web_security.c:409414` and `src/ssh_security.c:156181`, as well as ESP-TLS identity loading. Neither trusted storage nor a post-parse key check is being used as the exclusion: the decisive evidence is the built-in ECP path. Advisory identifies `0e2d7037db4048dbf1c194508c07384a818261d5` as the 3.6.x fix; no backport is installed or needed to close this configuration-specific finding. Reassess before moving to driver-only ECC.
**CVE-2026-49300:** fetched the official [basicConstraints advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2026-07-x509-ca-bit-forgery-invalid-basicconstraints.md). Installed `x509_crt.c:494555` lacks the inner-SEQUENCE length equality check and accepts INTEGER in place of BOOLEAN. The extension caller at line 966 supplies `end_ext_octet`, so comparison against the exact inner sequence is still necessary. First fixed 3.6.x release: **3.6.7**.
Current `src/web_security.c` generates its own P-256 self-signed identity, `basicConstraints(false, -1)`, and parses persisted identity at lines 409414. Validation checks matching keys, expected subject/issuer, self-signature, and specifically rejects `ca_istrue != 0` at lines 442444. `src/web_server.c:673691` retains default `cacert_pem=NULL`; generated ESP-TLS selects `MBEDTLS_SSL_VERIFY_NONE` for server peer authentication (`CONFIG_ESP_TLS_SERVER_MIN_AUTH_MODE_OPTIONAL` absent). Application authentication is not certificate-chain enrollment. Targeted application searches found no outbound ESP-TLS/HTTP client or untrusted certificate/CSR import; Wi-Fi configuration uses personal PSK/SAE profiles, not EAP credential/CA provisioning. These are current-use observations, **not global removal of X.509/client/enterprise support** (those libraries/features are available). A corrupt local blob being rejected is not evidence that the library parser is fixed.
Backport provenance correction: fetched [sequence fix `07f45b87681c1a0680c260089d3e6349b25fdd08`](https://github.com/Mbed-TLS/mbedtls/commit/07f45b87681c1a0680c260089d3e6349b25fdd08.patch). The advisory's listed “Basic fix #2,” [`4fb9c9e439fd1e7e44697d23d50f00b4642fbe08`](https://github.com/Mbed-TLS/mbedtls/commit/4fb9c9e439fd1e7e44697d23d50f00b4642fbe08.patch), changes **test encodings only**. A bounded official commit-history query for `library/x509_crt.c` at tag `mbedtls-3.6.7` located the actual [INTEGER-rejection source change `f67b8e5bded0c531b29480b8c5b7285366b1b153`](https://github.com/Mbed-TLS/mbedtls/commit/f67b8e5bded0c531b29480b8c5b7285366b1b153.patch), also fetched and compared. Do not backport the advisory's two listed hashes and claim both source defects fixed. No parser backport or runtime vector validation was performed here.
**CVE-2026-54435:** fetched the official [optimized modp side-channel advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2026-07-ecc-optimized-modp-side-channel.md). First fixed 3.6.x release: **3.6.7**. Installed `ecp_curves.c` retains specialized P-256 reduction and data-dependent carry/sign handling (`NEXT`, `LAST`, `mbedtls_ecp_fix_negative`, around lines 49005036); no override replaces it. Application key generation/loading and certificate signing genuinely use P-256; this is **not an unused-crypto finding**. Upstream requires privileged-local sufficiently precise traces (typically an untrusted OS attacking an enclave); physical side channels are also possible, not measured here. Disabling NIST optimization is an upstream workaround for NIST curves only, carries substantial performance cost, and does not cover Koblitz/Montgomery curves. No configuration change is recommended blindly on this resource-constrained target. Reopen for a local/physical attacker threat model or deliberate maintained-library update; the upstream multi-commit fix range was identified in the advisory but not patch-audited in this excluded slice.
**CVE-2026-50586:** fetched the official [TLS 1.2 ticket disclosure advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2026-07-tls12-newsessionticket-information-disclosure.md). First fixed 3.6.x release: **3.6.7**. Original `ssl_tls12_server.c:42424246` still declares uninitialized `tlen`/`lifetime`; failed ticket generation may disclose four stack bytes. `esp_https_server.h:183192` defaults client CA to NULL and `session_tickets=false`; the application preserves both. Generated HTTPS `create_secure_context()` initializes tickets only under `config->session_tickets`; generated ESP-TLS registers callbacks only under `CONFIG_ESP_TLS_SERVER_SESSION_TICKETS` and non-NULL `cfg->ticket_ctx`. Neither prerequisite holds. This is disabled **server-side TLS session tickets**, not application WebSocket/admin tickets, cookies, or compiled-out mbedTLS ticket support. Reassess and backport/init both outputs before enabling the feature; advisory lists `99ccd257e2d6c5fc53bc970e3e533a90c363f8e1` and `548ed19f707565db5fb4c2487edd7ae1bea50199` (not patch-verified here).
**CVE-2025-48965:** re-fetched the official [ASN.1 advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2025-06-6.md). First fixed release: **3.6.4**. Rechecked original stale-length branch and fixed-format CN generation (`src/web_security.c:92111,128240`); subject and issuer are separate fresh lists, not repeated OIDs within one list. No application direct named-data/string-name API consumer besides those fixed name setters was identified. This is not the separately addressed OID-allocation failure. A bounded official history query for `library/asn1write.c` at `mbedtls-3.6.4` resolved and fetched [fix `2df7ab7c0c3d5bb8a31481073c494521d10d4eba`](https://github.com/Mbed-TLS/mbedtls/commit/2df7ab7c0c3d5bb8a31481073c494521d10d4eba.patch): exactly `cur->val.len = 0;` after clearing `cur->val.p`, plus repeated-OID tests. Its context matches installed lines 412415. **The older unresolved-fix-commit statement below is now resolved; the source bug remains unpatched.** If backported defensively, select actual nested target `mbedcrypto`, retain source pin/notices and allocation-failure preservation, and test nonempty→empty→same-length replacement and serialization.
### Bounded IDF advisory-index snapshot: disabled/unused features
Fetched [official IDF index, page 1](https://github.com/espressif/esp-idf/security/advisories) on **2026-09-16**: ten entries spanning February 3September 3, 2026, newest listed A2DP/L2CAP. Fetched each of the eight remaining feature advisories below; the other two are DHCP and WS already covered above. **Pages 23, broader historical/vendor advisory coverage and future publications were not reviewed.** Later affected-release labels do not establish that installed 5.5.0 is fixed; dispositions here rely on feature/target/caller absence, not release-table inference.
| Official advisory / CVE | Required feature | Snapshot disposition and local evidence |
| --- | --- | --- |
| [GHSA-xcpr-5mqp-9qvv](https://github.com/espressif/esp-idf/security/advisories/GHSA-xcpr-5mqp-9qvv), CVE-2026-81508 | Bluedroid Classic A2DP sink; paired BR/EDR source | **Not-current-path**: BT disabled; no BT host compile inputs. |
| [GHSA-v335-fxwc-rc44](https://github.com/espressif/esp-idf/security/advisories/GHSA-v335-fxwc-rc44), CVE-2026-81507 | Bluedroid Classic L2CAP configuration | **Not-current-path**: same disabled BT evidence; no claim that authentication would mitigate this unauthenticated path. |
| [GHSA-3pp8-42fh-3j3c](https://github.com/espressif/esp-idf/security/advisories/GHSA-3pp8-42fh-3j3c), CVE-2026-46532 | Bluedroid AVRCP target, paired BR/EDR peer | **Not-current-path**: same disabled BT evidence. |
| [GHSA-9r76-858f-v6jh](https://github.com/espressif/esp-idf/security/advisories/GHSA-9r76-858f-v6jh), CVE-2026-45542 | NimBLE protocomm Security2 provisioning | **Not-current-path**: BT/NimBLE absent; no application protocomm/Wi-Fi provisioning API use found. SoftAP itself is not this provisioning service. |
| [GHSA-h7r3-gmg9-xjmg](https://github.com/espressif/esp-idf/security/advisories/GHSA-h7r3-gmg9-xjmg), CVE-2026-25507 | BLE provisioning teardown with `keep_ble_on=true` | **Not-current-path**: same disabled BLE/no provisioning service evidence, not a verified vendor UAF fix. |
| [GHSA-v6r2-f6p2-88cj](https://github.com/espressif/esp-idf/security/advisories/GHSA-v6r2-f6p2-88cj), CVE-2026-55687 | Hardware JPEG decoder DQT parsing | **Not-current-path**: ESP32-S3 configuration, no `esp_driver_jpeg` compilation input or application JPEG calls. No malformed-image testing. |
| [GHSA-w82j-7q63-7pqm](https://github.com/espressif/esp-idf/security/advisories/GHSA-w82j-7q63-7pqm), CVE-2026-45329; [GHSA-mmgp-73p4-92xp](https://github.com/espressif/esp-idf/security/advisories/GHSA-mmgp-73p4-92xp), CVE-2026-45328 | ESP-TEE REE/TEE secure-service wrappers | **Not-current-path**: target is Xtensa ESP32-S3, not the advisory's supported RISC-V TEE SoCs; no `esp_tee` compilation inputs or enabled TEE setting. This exclusion is distinct from choosing not to implement secure boot/encrypted NVS. |
Local snapshot evidence: generated `sdkconfig.h` selects `CONFIG_IDF_TARGET="esp32s3"` / `CONFIG_IDF_TARGET_ARCH_XTENSA=1`; no enabled `CONFIG_BT_ENABLED`, `CONFIG_BT_BLUEDROID_ENABLED`, `CONFIG_BT_NIMBLE_ENABLED` or ESP-TEE option. Filtered existing compilation database counts for `/bt/host/`, `/esp_tee/`, `/esp_driver_jpeg/`: **0 each**. `src/` searches for provisioning/protocomm/JPEG APIs found none. This is a feature screen, not inspection or certification of the disabled vendor implementations. Re-enabling any feature invalidates its disposition.
### Parent handoff and remaining limits
**No new current-path actionable source correction established among these six named findings.** Keep the existing DHCP/EMS/OID-allocation fixes. Optional defense-in-depth candidates are the exact ASN.1 one-line fix, ECDH curve-width bounds check and both basicConstraints source fixes; they must not be represented as already implemented. The basicConstraints test-only commit mismatch is actionable **backport guidance**, not evidence of a present authentication bypass. Preserve server ticket disablement and re-review before introducing certificate imports, driver-only ECC, arbitrary-curve ECDH callers or local isolation requirements.
Completed evidence work: official six-advisory fetches, focused upstream patch comparisons/history queries, original-source/config/caller inspection, read-only equality checks of six IDF generated overrides, and filtered configured-input inspection. Document-scoped `git diff --check` and the corrected document-presence/link-count check passed (the initial checker incorrectly expected twice the number of advisory URLs). Concurrent changes appeared in other agents' parser/override/release-notice files during the review; none were edited or reverted here, and source/configuration evidence above is the inspected snapshot, not certification of subsequent changes. No exploits, new host regression tests, build or device validation were run. The finite applicability questions above are dispositioned; full advisory coverage, runtime fault/timing behavior and whole-phase target acceptance remain unresolved. **Do not summarize this as “all CVEs safe.”**
## Implementation addendum — 2026-09-15
**Implemented, with host/build evidence; not target validation or a completed advisory review.** Source checked against `tools/security_overrides.py`, `cmake/security_overrides.cmake` and the existing generated copies:
- **CVE-2026-45160:** pinned DHCP backport handles PAD/END before length access, validates remaining payload length and minimum type/IP lengths, and advances by the validated length. Remaining-length comparisons deliberately avoid forming out-of-object pointers.
- **CVE-2026-50581:** TLS 1.2 EMS `calc_verify` failure now returns `ret` before derivation. This is the TLS 1.2 hunk only, not the upstream TLS 1.3 correction or a global mbedTLS update.
- **CVE-2026-34874:** X.509 OID allocation failure now returns `MBEDTLS_ERR_X509_ALLOC_FAILED` before `memcpy()`.
- The registry now has **seven** entries: the four historical entries below plus `dhcpserver` on `lwip`, `mbedtls_ssl_tls` on nested target `mbedtls`, and `mbedtls_x509_create` on nested target `mbedx509`. Nested-target selection validates the allowlist and owning SDK library directory, rejects imported/alias targets, and retains the exactly-one-source/property-preservation checks. All seven generated copies were read-only verified against rendered pinned inputs; each carries the project modification notice dated **2026-09-15**, retaining upstream notices.
- **Still unpatched:** CVE-2026-45541 WS negotiation and CVE-2025-48965 ASN.1 named-data state. Their baseline applicability qualifications below remain; the three backports do not fix them.
Supplied parent evidence: `pio run` **PASS**, **94,340 B linked RAM / 1,767,217 B flash** (unchanged RAM, **64,092 B flash** from the 9C 1,831,309 B baseline; includes the wolf crypto policy). Parent reports all four focused commands in [hardening validation](security_hardening.md#mitigation-hostbuild-evidence--2026-09-15) passed; independent reviewer reports the SDK-override and wolf-crypto-policy suites passed with no blocking implementation defects. These commands were not rerun for this documentation update. No target evidence, dependency upgrade, asset regeneration or device operation is claimed. Broader advisory review and whole-phase acceptance remain open.
## Historical pre-mitigation research baseline — 2026-09-15
**The remainder preserves the original investigation, hashes, applicability and proposed tests. Statements such as “not implemented,” “current,” four entries and original compilation inputs describe that earlier snapshot, not the implementation above.**
Review date: **2026-09-15**. Scope: the installed `/home/mscholz/.platformio/packages/framework-espidf`, current application source, and checked-in/generated security overrides. **Review and recommendations only; no fixes implemented.** No build, test compilation, upload, monitor, erase, or device operations were performed.
This is not a complete IDF/mbedTLS advisory inventory, certification, or statement that an old version is safe. Advisory release tables are not sufficient to identify downstream backports: the installed implementation and actual source registration were inspected.
## Decision summary
| Issue | Confirmed local finding | Application applicability | Recommended priority |
| --- | --- | --- | --- |
| CVE-2026-45160, DHCP option parsing | Original vulnerable parser compiled; no override | Exposed whenever the project SoftAP/DHCP server is active; adjacent associated client, no application login required | **First correction:** backport the release/v5.5 bounds fix |
| CVE-2026-45541, WS subprotocol parsing | Vulnerable SDK function compiled; no override | Faulty tokenization loop **not reached by current application upgrades**, which pass `NULL`; both routes are ordinary HTTP handlers | Defense-in-depth backport; preserve current admission-before-101 design |
| CVE-2026-50581, ignored EMS error | Missing early return in installed `ssl_compute_master()` | TLS 1.2/EMS path used by HTTPS; hash/allocation failure prerequisite. Cipher restriction and disabled renegotiation do not remove this path | Prompt fail-closed correction alongside DHCP/X.509 work |
| CVE-2025-48965, ASN.1 named-data state | Zero-length replacement clears pointer but leaves stale length | Current certificate names are trusted, single nonempty CNs; malicious repeated-OID/empty-value trigger not provided by current name generation | Lower-priority library correction/regression coverage |
| CVE-2026-34874, X.509 name allocation failure (3.6.6 fix) | Unchecked allocation immediately followed by `memcpy()` | **Used by local certificate creation**, even with the safe fixed CN; OOM can trigger it | Prompt correction; fixed input is not a mitigation |
**Important correction to issue classification:** CVE-2025-48965 is not the X.509 allocation-failure bug. Its official advisory describes a stale `val.len` after a zero-length replacement. CVE-2026-34874 is the separate unchecked allocation fixed in 3.6.6. Do not merge their applicability or mark either fixed by the project's HTTPD scratch allocator changes.
## Exact reviewed baseline and registration
Installed `components/esp_common/include/esp_idf_version.h` defines **5.5.0**. `components/mbedtls/mbedtls/include/mbedtls/build_info.h` defines **3.6.3**, `0x03060300`. The following SHA-256 values were calculated from installed bytes, not inferred from version labels. Paths are relative to the installed IDF root:
| Installed source | SHA-256 |
| --- | --- |
| `components/esp_common/include/esp_idf_version.h` | `30fdfbed6cc0055a89f3454f61b56d5e5643bbc46c0774aae63e41869cf2947a` |
| `components/lwip/apps/dhcpserver/dhcpserver.c` | `953f46189bc64680ea5fa761e75511fadb3aebf698a0d9dff251d77166d78b80` |
| `components/esp_http_server/src/httpd_ws.c` | `e514f603a696e3e775573bc8882a350691e31fcae96e435f9062248e7c91677c` |
| `components/mbedtls/mbedtls/library/ssl_tls.c` | `b726c0c55bc5f32255f129d55f9f2fface85ce83de90a2d16c9017b93b738bff` |
| `components/mbedtls/mbedtls/library/asn1write.c` | `6667aebb1e5f0500ef4a281fe2752fc7bc59610e7bfc01d2916d224e5bc5c3e3` |
| `components/mbedtls/mbedtls/library/x509_create.c` | `fd399239aee30384786a19b47bfe5dd22b979d5d89bb38f29f0c82a3d81daaf7` |
### Checked-in edits are part of the effective source
Root `CMakeLists.txt:1516` includes `cmake/security_overrides.cmake` after `project()`. That file invokes `tools/security_overrides.py`, loads its manifest, and replaces exactly one source on the resolved component target, preserving source properties and original quoted-include directory. Version/source/hash/edit ambiguity fails configuration.
The registry currently has exactly four entries:
| Entry | Component | Protected original SHA-256 |
| --- | --- | --- |
| `wolfssh_internal` | `wolfssl__wolfssh` | `81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9` |
| `https_server` | `esp_https_server` | `6481942b62e51125e2a43441fa0900cbda74bd2ea05c82f0c29eb4933c31946e` |
| `httpd_parse` | `esp_http_server` | `6bba77064aaa68a06f8d4c01432064a1b050c91ed22741c547785b0d8a6c07d8` |
| `esp_tls_mbedtls` | `esp-tls` | `09210c5a601647ca5775d127a2951bab2f3e509192b53487bbea8a93d8731b78` |
Read-only Python inspection called `verify_version()` and `render_entry()` in memory (not `generate()`), verified all four original pins, and compared rendered bytes with the existing generated files: **all four matched exactly**. This did not rewrite installed or generated files.
The existing `.pio/build/esp32-s3-devkitc-1-n16r8/security_overrides/manifest.cmake` and narrowly filtered `compile_commands.json` register those four generated sources, but register **original** `dhcpserver.c`, `httpd_ws.c`, `ssl_tls.c`, `asn1write.c`, and `x509_create.c`. Thus HTTPS cleanup, HTTPD scratch wiping/null handling, TLS policy, and wolfSSH fixes do **not** constitute fixes for these five findings.
This confirms existing configured compilation inputs, not freshness of the executable, successful rebuilding, or the firmware installed on a device.
### mbedTLS registration constraint
Installed `components/mbedtls/CMakeLists.txt` imports upstream nested targets. The existing compile database's object paths confirm:
- `ssl_tls.c` → target `mbedtls` (`.../CMakeFiles/mbedtls.dir/ssl_tls.c.obj`).
- `x509_create.c` → target `mbedx509`.
- `asn1write.c` → target `mbedcrypto`.
The current replacement helper resolves an **IDF component's** `COMPONENT_LIB`; it does not resolve these nested library targets. Simply adding `Entry(..., "mbedtls", ...)` would select the component wrapper, not the actual owning target, and should fail its exactly-one-source check. A future patch must minimally add an explicit, validated target-selection mechanism for these three nested targets while retaining current component resolution for existing entries. Do not weaken the match check or add duplicate source compilation to the wrapper.
## CVE-2026-45160 — DHCP server `parse_options()`
Official [GHSA-g764-gwc3-75m5](https://github.com/espressif/esp-idf/security/advisories/GHSA-g764-gwc3-75m5) and fetched [release/v5.5 patch](https://github.com/espressif/esp-idf/commit/d51b1076092487e533eadf8b48c9c8579d3a6712.patch).
**Exact upstream fix commit:** `d51b1076092487e533eadf8b48c9c8579d3a6712`.
Confirmed installed `dhcpserver.c:902952`:
- Loop only checks `optptr < end`.
- Message type reads `optptr[2]` without validating a length byte or payload.
- Requested IP compares four bytes without a declared/remaining-length check.
- Advancement reads `optptr[1]` without ensuring it exists.
- PAD is not treated as a one-byte option.
The GHSA lists later affected point releases and a planned 5.5.5 fix; that is **not evidence excluding 5.5.0**. The old implementation is directly present here.
### Reachability
`src/wifi_manager.c:create_default_wifi_netifs()` explicitly builds `ESP_NETIF_DEFAULT_WIFI_AP()` and installs default AP handlers. Installed `esp_netif_defaults.h:5961` sets `ESP_NETIF_DHCP_SERVER | ESP_NETIF_FLAG_AUTOUP`. Existing generated configuration has `CONFIG_LWIP_DHCPS=1`. `start_radio_and_policy()` selects AP/APSTA when policy requires it; `set_runtime_ap_enabled()` also permits STA→APSTA activation.
This is therefore an applicable exposure when SoftAP is active, not a hypothetical unused SDK feature. A station-only moment is not a permanent mitigation if fallback or administrative policy can enable AP later. The official impact is adjacent-network denial of service, not demonstrated heap disclosure: the advisory says overread bytes are not echoed. WPA association controls reduce access but do not fix the parser. No live attack or actual device AP state was tested.
### Minimal recommended edits (not implemented)
Add a pinned `idf` entry for `components/lwip/apps/dhcpserver/dhcpserver.c`, component `lwip`, using the exact original hash above. Backport the official patch's PAD definition and parser changes:
1. Skip PAD; terminate at END before reading a length.
2. Require a length byte and the entire declared payload to fit.
3. Require message-type payload length ≥1 and requested-IP length ≥4.
4. Advance using the already-validated `opt_len`; remove the obsolete end flag.
5. Retain notices and existing post-loop state behavior.
For a strict-C bounds implementation, consider remaining-length comparisons (`end - optptr >= 2`, then `opt_len <= remaining - 2`) rather than constructing a pointer beyond the object in the upstream `optptr + 2 + opt_len > end` check. If chosen, document this small deviation and test equivalence; this review did not apply or test either form.
Future tests: empty options, lone option code, missing length, oversized payload, PAD sequences, END at boundary, short type/IP fields, valid DISCOVER/REQUEST/RELEASE and option combinations. Verify actual generated `lwip` compilation input, no duplicate original, and no changes to AP policy, broker, UART0, or USB recovery.
## CVE-2026-45541 — WS subprotocol negotiation
Official [GHSA-3j8v-xgrq-5vg8](https://github.com/espressif/esp-idf/security/advisories/GHSA-3j8v-xgrq-5vg8) and fetched [release/v5.5 patch](https://github.com/espressif/esp-idf/commit/00a2f7fbbbd8fe6d04729022e1d5c9a49435bfe8.patch).
**Exact upstream fix commit:** `00a2f7fbbbd8fe6d04729022e1d5c9a49435bfe8`.
Installed `httpd_ws.c:7095` has `strtok_r()` followed by a `do` loop that dereferences its possibly NULL result. It also compares using `sizeof(subprotocol)`, the pointer size rather than protocol length. A nonempty delimiter-only header can yield no tokens.
### Current route registration avoids the defective loop
- Existing configuration has `CONFIG_HTTPD_WS_SUPPORT=1`; disabling WS at compile time is **not** the reason for non-reachability.
- `src/web_server.c:518539` defines serial and admin upgrade routes with `.is_websocket = false`; omitted `supported_subprotocol` members of these static initializers are NULL. `start_server()` actually registers the serial handler array and optional admin route.
- `src/web_serial_transport.c:745` and `src/web_admin_transport.c:570` use `web_httpd_upgrade()` after admission.
- `src/web_httpd_adapter.c:174` calls `httpd_ws_respond_server_handshake(request, NULL)`.
- Installed `httpd_ws_get_response_subprotocol()` returns at its `supported_subprotocol == NULL` check **before** tokenization. The optional registration adapter also rejects `is_websocket` or `supported_subprotocol`.
Conclusion: vulnerable library bytes are present, but **this CVE's NULL-token dereference is not reachable through the current application upgrade paths**. The official generic warning about pre-application-authentication crashes applies to automatic SDK WS negotiation with a non-NULL supported protocol; it should not be copied onto these manual-admission routes without this qualification. TLS alone is not a general mitigation.
### Minimal recommended edits (not implemented)
Add a separately pinned `httpd_ws.c` entry for component `esp_http_server`; do not conflate it with the existing `httpd_parse.c` entry. Backport the upstream `while (s != NULL)` loop, exact-length match and explicit next-token step, retaining notices. Do not enable automatic upgrades or add a subprotocol to justify the patch.
Future tests: empty/delimiter-only headers, NULL supported protocol, unsupported protocol, exact match and prefix mismatch, plus current cookie/Origin/ticket admission-before-101 behavior. Mark it as a defense-in-depth fix, not closure of a demonstrated unauthenticated application crash.
## Bounded mbedTLS 3.6.3 review
### CVE-2026-50581 — EMS calculation failure ignored
Official [advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2026-07-extended-master-secret-calculation-failure-ignored.md), published 2026-07-07. Affects through 3.6.6; first fixed 3.6.x release is **3.6.7**, not 3.6.6.
Fetched [basic fix](https://github.com/Mbed-TLS/mbedtls/commit/f595df4569c1a1650ad9d077e2f2e819e9f1dddb.patch): **`f595df4569c1a1650ad9d077e2f2e819e9f1dddb`**. Advisory gives test/documentation range `338572c1d805a31b875a448536bcb50d72f9bc40..27065ceb643a4266888ba1f200e4f20845e801cf`; that range was not reviewed here.
Confirmed installed `ssl_tls.c:70417085`: `session_hash[48]`, initial `seed_len=64`, and `handshake->calc_verify()` error only logged; execution continues. The override edits `esp-tls/esp_tls_mbedtls.c`, **not** this file. The server's two ECDHE-ECDSA AES-GCM suites use SHA-256/SHA-384 and TLS 1.2; EMS remains relevant even without renegotiation. Installed `esp_config.h:10941097` enables EMS when `CONFIG_MBEDTLS_TLS_ENABLED`, which is 1 in the existing generated header.
A calculation/allocation failure can leave an uninitialized hash and potentially retain length 64, causing a 16-byte buffer overread. Per upstream, this does not directly disclose stack contents or the master secret; a correctly functioning peer rejects the incorrect Finished message. Higher-level master-secret uniqueness attacks require additional conditions, including both peers affected and induced failures. **No direct HTTPS login bypass, practical remote fault trigger, or device exploit was established.** Low historical memory minima justify fault testing, not an assertion that this failure occurred.
**Minimal recommendation:** pinned `ssl_tls.c` replacement on actual target `mbedtls`, inserting `return ret;` immediately after the existing `MBEDTLS_SSL_DEBUG_RET(1, "calc_verify", ret)` inside the error branch. Preserve EMS; do not disable it as a shortcut. The fetched commit also changes `ssl_tls13_server.c` for a separate resumption-secret error: do not claim the one-line TLS 1.2 backport implements that second fix or globally updates mbedTLS. Current HTTPS policy is TLS 1.2 only.
Future tests should inject `calc_verify` failure for both SHA paths, ensure PRF/key derivation and successful handshake progression do not occur, preserve success behavior, and exercise cleanup. Exact driver/backend failure inducibility on the target remains unresolved; no preprocessing/cryptographic or fault-injection test was run.
### CVE-2025-48965 — ASN.1 named-data inconsistency, not OOM
Official [advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2025-06-6.md) and [3.6.4 release notes](https://github.com/Mbed-TLS/mbedtls/releases/tag/mbedtls-3.6.4). Affects through 3.6.3; fixed in 3.6.4.
Installed `asn1write.c:374435` checks allocation failures when creating a node/OID/value and preserves old data until replacement allocation succeeds. However, the `val_len == 0` branch frees `cur->val.p` and sets it NULL without resetting `cur->val.len`. Reusing the same OID or writing the inconsistent list can dereference NULL. Official examples include `DC=foo,DC=#0000` and `DC=foo,DC=#0000,DC=bar`.
`src/web_security.c:92111,128257` builds `CN=ESP32 SAK <six hex digits>` from the MAC, initializes a fresh writer, and sets subject and issuer separately. There is no repeated OID or empty value in either name. Thus the faulty primitive exists and is indirectly called, but **the official hostile-name trigger is not supplied by this certificate-generation path**. Do not say “not applicable because the project never creates certificates”; it does. Do not treat separate subject/issuer lists as repeated attributes in one list.
**Minimal recommendation:** ensure `cur->val.len = 0` when clearing the value, on actual target `mbedcrypto`, with a pin on the installed file. Retain the existing preserve-old-on-allocation-failure logic. The official advisory/release was fetched, but **an exact upstream CVE-2025-48965 fix commit was not resolved or patch-verified in this bounded review**; obtain and compare the specific 3.6.4 change before implementing/pinning a claimed official backport. Test nonempty→empty→same-length replacement, list serialization after empty replacement, repeated OIDs, and allocation failures independently.
### CVE-2026-34874 — `x509_string_to_names()` allocation failure
Official [advisory](https://raw.githubusercontent.com/Mbed-TLS/mbedtls-docs/main/security-advisories/mbedtls-security-advisory-2026-03-null-pointer-dereference-x509.md) and [3.6.6 release notes](https://github.com/Mbed-TLS/mbedtls/releases/tag/mbedtls-3.6.6). Affects 3.5.03.6.5; fixed in 3.6.6.
Fetched [basic fix](https://github.com/Mbed-TLS/mbedtls/commit/bfaf4a47fd33da860796feaba6235847acb71127.patch): **`bfaf4a47fd33da860796feaba6235847acb71127`**. The advisory identifies tests/documentation through `4704b6b4bd963f1331582374e881184addf8f523`; those additional changes were not reviewed here.
Installed `x509_create.c:306310` allocates the known attribute's OID and calls `memcpy(oid.p, ...)` without checking NULL. The project's CN takes exactly this recognized-attribute branch through both name setters. Trusted names avoid the previous CVE, **not this OOM defect**. Application return-code checks cannot catch a fault that happens inside the setter before it returns.
**Minimal recommendation:** pinned `x509_create.c` replacement on actual target `mbedx509`; directly after `oid.p = mbedtls_calloc(1, oid.len)`, add the official three-line NULL guard returning `MBEDTLS_ERR_X509_ALLOC_FAILED`. Preserve existing application cleanup and fail-closed identity publication. No new buffer or policy change is required.
The upstream advisory discusses arbitrary code execution on microcontrollers where address zero is writable. **That impact was not demonstrated for this ESP32-S3 memory map**; confirmed here is the unchecked write path and potential failure during certificate generation, not practical code execution. Test OID allocation failure at each setter, clean writer destruction, no persistence/publication of incomplete material, preservation of the old identity on failed rotation, and successful retry.
## Actionable next correction and remaining evidence
1. **Implement the pinned DHCP parser backport first.** It is the clearest currently reachable unauthenticated network-input defect in this scope. Do not merely change advisory wording or a version label.
2. In the same security correction effort, support explicit nested-target overrides and backport the EMS early return and X.509 allocation guard. Verify each protected source appears exactly once on its real owner target; retain all existing four corrections and their source properties.
3. Backport WS defensively and add registration/NULL-subprotocol regression coverage without changing application admission. Resolve the exact ASN.1 3.6.4 patch before adding that lower-priority correction.
4. Before acceptance, run focused parser/allocation/error-propagation tests against the generated compilation inputs, then a firmware build and target checks under separately authorized implementation/validation work. **None were performed for this review.** Ensure failures cannot compromise UART0 recovery, native USB independence, broker ownership, or bounded transport isolation.
5. A full IDF upgrade is an alternative maintenance strategy, but must rebase/re-audit all existing source pins, private HTTPD contracts, and TLS behavior. Updating only to mbedTLS 3.6.6 would leave CVE-2026-50581 unresolved. This document does not certify any upgrade candidate against the full advisory set.
Unresolved boundaries: exact ASN.1 fix commit; target-specific OOM/hash failure reproducibility and impact; actual running firmware/configuration; complete mbedTLS/IDF advisory coverage and downstream release integration. Existing source registration and generated-byte equality are confirmed; patch application, tests, linking, and device behavior are not.
Review mechanics: project code-map/current-state read first; targeted source, configuration and compilation-input inspection; official advisories and four exact fix patches fetched. No large release-history index was used. Some GitHub API requests timed out/returned no results and direct ReadTheDocs access via Python returned HTTP 403; official raw advisory sources and individual release pages supplied the evidence above. One read-only inspection command hit its 10-second timeout; a linear-time replacement completed the inspection. No source modifications were made, and this review owns only this document.
+4 -42
View File
@@ -1,38 +1,6 @@
# Legacy credential removal
# Legacy credential removal and storage compatibility
## 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; **the user signed off the legacy-credential cleanup on 2026-09-08**. 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).
## Target sign-off — 2026-09-08
User confirms: “the cert fingerprint did not change and I could use the preexisting test users. So that is a sign off”. Accept the legacy-credential/bootstrap cleanup based on this identity/account continuity confirmation and the full-mix telemetry below. This supersedes earlier pending-signoff/fingerprint-unconfirmed statements, not historical test attribution or evidence limits. Ordinary HTTPS idle cleanup retains its prior scoped acceptance. No wider phase/M3 acceptance, individual unreported provisioning/recovery/fault-injection test, all-key coverage, soak duration or numeric reserve approval is inferred.
User suspects the two boot authentication failures came from the two browser tabs reconnecting with pre-flash stale sessions. This is consistent with two failures already at boot, no further failures under load and zero invalid credential attempts, but remains a user-attributed likely explanation rather than a traced request diagnosis.
This sign-off update changes documentation only: no source/config changes, build/test rerun, upload, erase, asset generation or commit.
## User-supplied post-cleanup target evidence — 2026-09-08 (before sign-off confirmation)
User supplies a fresh-boot sample at 60 seconds and a full-client-mix sample with substantial reported test traffic. This confirms operational password/public-key login and the supported connected mix in this run, not explicit cleanup sign-off, exact certificate preservation, every account/key, blank-device provisioning, recovery, or power-loss behavior. Exact flashed revision, browser, duration, diagnostic enable state and before/after certificate fingerprint were not supplied.
| Memory (bytes) | Boot free / minimum / largest | Full mix free / minimum / largest |
|---|---|---|
| Internal 8-bit | 66,488 / 61,268 / 31,744 | 31,820 / 15,740 / 20,480 |
| Internal DMA | 58,732 / 53,512 / 31,744 | 24,064 / 7,984 / 20,480 |
| PSRAM | 8,246,148 / 8,184,056 / 8,126,464 | 8,112,076 / 8,074,196 / 7,995,392 |
Per-region lifetime minima are conservative/non-simultaneous; internal/DMA capability sets overlap. Loaded minima exceed the pre-idle-cleanup diagnostic capture (1,984/384 internal/DMA), but workloads differ and this is not causal attribution or reserve approval. SSH owner stack is 20,480 bytes, minimum-free 18,476 boot / 16,284 loaded; HTTPD/dispatcher margins unreported.
Boot: SSH/HTTPS started once with no startup failures, mDNS announced/ESP_OK, UART stopped with 230400 8N1 RTS/CTS/DTR-active configuration, USB attached/host closed, no broker clients or SSH sessions. Five accounts/two admins. HTTPS already processed two unauthenticated requests (two auth failures), but login attempts/invalid credentials remain zero; this is not a completely request-free baseline. Source of the requests is unknown, not evidence of bad passwords.
Loaded: USB client16 is the sole writer, SSH client9 and web clients10/11 observers, with SSH and browser admin consoles active. Two SSH sessions authenticate by public key as user/admin; two cookie logins authenticate by password as those roles. No specific key algorithm or exact verifier/key identity is established. UART at 230400 8N1 RTS/CTS/DTR active, all sampled UART/broker pending/events zero; no broker drop-counter output was supplied. User reports much traffic, but output does not establish bidirectional exercise of every route: browser serial RX and browser admin RX are zero in the supplied counters.
SSH: TCP/handshake success/auth attempts 2/2/2, all handshake/auth/timeouts/capacity/request rejection/IO/session-revocation failures zero; broker connect1, writer request1/denial1, no grants/revocations; admin admission1/no failures or backpressure. Stream RX/accepted/rejected 34/34/0, TX 299,932 bytes. Browser admin ticket issued/consumed1/1, connected1/disconnected0, TX136/RX0, all rejection/capacity/protocol/send/queue/backpressure counters zero. Web requests65/authenticated63/auth failures2 (unchanged from boot), root2/status56/tickets2/assets2, response errors0. Serial tickets issued/consumed2/2, no expired/rejected; serial WS connect2/disconnect0, no connection/service/broker failures. TX1,077 binary frames/495,147 bytes, control9 frames/774 bytes, RX0. Writer requests2/denials2, grants/releases/revocations0, send/queue/protocol/close counters0. Cookie sessions2/4, login attempts2, invalid/throttled/capacity/CSRF/logouts0. These counters support clean established WebSocket operation in this capture, not a measured first-attempt admission latency or absence of all pre-HTTP TLS failures.
No code/config changes, build, device commands or explicit sign-off were performed/inferred from this evidence update. Remaining target checks include unchanged HTTPS fingerprint, existing-account/key coverage, blank-device first admin and unavailable-only recovery as appropriate, plus longer cleanup/traffic observations if desired.
Current storage/recovery contract. The user accepted cleanup on 2026-09-08, confirming the certificate fingerprint was unchanged and preexisting test users remained usable. [Overall acceptance and evidence limits](web_administration_acceptance.md) supersede the old per-slice handoff; no unreported provisioning, recovery, power-loss or all-key tests are implied.
## User database
@@ -56,12 +24,6 @@ The v2 candidate is validated and committed before live publication. Temporary c
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
## Regression references
- 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.
Use `tests/web_security/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py` and [current legacy-removal procedures](user_administration_tests.md#current-legacy-removal-regression-procedure). Prior host tests used NVS fault doubles and a generated identity wrapped in the historical TLS layout, not an independently captured old-device fixture; they do not prove flash durability or power-loss behavior. Procedures are not claims of execution.
-152
View File
@@ -1,152 +0,0 @@
# Phase 8D.10 — Accounts and passwords
## Target acceptance — implemented 8D.88D.10 scope (2026-09-08)
**User sign-off:** after supplying settled clean-boot and full-client-mix telemetry, the user reports: “I also tested the serial parameter display and settings, as well user/account settings thoroughly. Implemented work can be signed off.” Record this as acceptance of implemented **8D.8, 8D.9 (including its automatic-completion UX), and 8D.10 (both Accounts slices)**. It supersedes target-pending/full-signoff-pending statements below and in the earlier phase records, not their evidence or implementation constraints. The current checkout/handoff includes the completed credential/self workflows and generated-password route; the exact flashed revision/build hash was not separately supplied. Do not turn the broad functional sign-off into invented individual checklist passes. M2 stands; no 8D.11 implementation is authorized by this sign-off.
### User-supplied memory samples
All values are **bytes**. Each heap cell is **free / minimum-free / largest-block**. Minimum-free is the firmware's conservative sum of matching regions' lifetime minima, not necessarily one simultaneous sample.
| Sample | Internal 8-bit | Internal DMA | External PSRAM |
|---|---:|---:|---:|
| Settled clean boot | 69,928 / 65,476 / 31,744 | 62,172 / 57,720 / 31,744 | 8,246,368 / 8,238,352 / 8,126,464 |
| Loaded sequence 1 | 46,016 / 19,832 / 31,744 | 38,260 / 12,076 / 31,744 | 8,235,292 / 8,225,380 / 8,126,464 |
| Loaded sequence 2 | 32,580 / 19,608 / 23,552 | 24,824 / 11,852 / 23,552 | 8,082,268 / 8,077,960 / 7,995,392 |
| Loaded sequence 3 | 32,444 / 19,452 / 23,552 | 24,688 / 11,696 / 23,552 | 8,084,008 / 8,069,816 / 7,995,392 |
| Loaded sequence 4 | 32,444 / 19,228 / 23,552 | 24,688 / 11,472 / 23,552 | 8,082,276 / 8,065,444 / 7,995,392 |
| Loaded sequence 5 | 32,556 / 19,228 / 23,552 | 24,800 / 11,472 / 23,552 | 8,086,084 / 8,065,444 / 7,995,392 |
| Loaded sequence 6 | 32,556 / 19,228 / 23,552 | 24,800 / 11,472 / 23,552 | 8,087,656 / 8,065,444 / 7,995,392 |
The first loaded sample precedes the settled later footprint; exact client occupancy at each memory command is not separately known. Later free/largest values are broadly stable across the supplied sequence, while lifetime internal/DMA minima fall through sample 4 and then remain unchanged. This short sequence is not a timed leak/fragmentation/soak proof or numeric reserve approval. SSH owner stack minimum-free is **18,476 B at boot / 16,284 B loaded**, of a 20,480-byte allocation. HTTPD/dispatcher stack margins were not supplied.
### Observed configuration and client mix
- Settled boot: HTTPS and SSH initialized/running, one successful start each, no start failures; web admin initialized/attached but inactive. No SSH/cookie/WebSocket/broker clients. USB attached but host-open/DTR/RTS false; UART service stopped and RS-232 owner idle. Five users/two admins. mDNS initialized and announced as `sak-1024.local`, ESP_OK.
- Serial configuration is **230400 baud, 8 data bits, no parity, 1 stop bit, RTS/CTS, DTR active, RTS threshold 96** at both boot and load. Loaded service owns the physical port; RX-available and TX-pending are zero at the snapshot. DSR/CTS and valid voltage asserted; DCD/RI not asserted.
- Loaded snapshot confirms **two SSH sessions**: role-user public-key broker writer **ID 8**, and public-key administrator on the separate console route. Four broker clients: SSH writer 8, USB observer 9, web observers 26 and 11. No queued client output or events in the supplied client table. This supports one writer/isolated observers; no broker drop-counter output was supplied.
- HTTPS has **two cookie sessions, two serial WebSockets, and one active admin WebSocket**. Web serial sessions belong to administrator and ordinary-user accounts, both observing. USB host-open/DTR/RTS true, broker observer. Reported USB line coding is diagnostic only; UART1 configuration remains explicit.
### Counters retained without unsupported diagnosis
- SSH: 3 TCP connects, 2 successful handshakes, **1 handshake failure**, zero handshake timeouts/auth failures/capacity rejection/IO failures. One successful broker admission/writer grant, one admin-console admission without failures/backpressure. Stream RX 73 accepted/0 rejected, TX 9,121. **One broker revocation**, zero session-revocations; cause unspecified.
- Web admin: 5 tickets issued/consumed, 5 connects/4 disconnects, one currently active; no capacity/auth/protocol/backpressure/send/queue failures. RX 0 / TX 408 bytes. These counters do not separately prove shell-command testing.
- Web serial: 4 connects/2 disconnects, no service-start/broker/connect failures. TX 75 binary frames / 12,478 bytes. Four writer requests denied while SSH holds the lease, consistent with single-writer policy. **One send failure and one close** are recorded alongside connection cycling; no cause or regression attribution is established. Queue/protocol failure counters are zero.
- General HTTPS: 86 requests/authenticated, zero reported auth/response failures in those counters; 6 serial tickets issued, 4 consumed, 2 expired. Cookie counters: 3 login attempts, **1 invalid credential**, zero throttle/capacity/CSRF-Origin rejections/logouts. No credential value is retained here.
- The final session snapshot has no closing or RX-pending serial clients. Admin-SSH command/output activity is consistent with running the status commands, not evidence of a wedged dispatcher.
### Acceptance and evidence limits
The user's thorough Serial/account functional testing and explicit sign-off close the implemented phase scope. Exact firmware hash, browser(s), elapsed settle/load/soak durations, mutation-by-mutation results, reboot-persistence/fault-injection/secret-expiry details and post-disconnection cleanup telemetry were not supplied separately. These limits do **not** reopen accepted phases or become invented test passes. Historical checklist items below remain useful regression guidance, not remaining acceptance blockers after user sign-off. Numeric reserves/runtime timer cost/HTTPD-dispatcher stack margins remain followups. The earlier accepted intermittent full-mix admission issue is neither diagnosed nor declared fixed by successful admission in this run.
The CLI's “no normal UI entry” and “shell/PTY only” labels are inherited status wording, not evidence that current UI/SSH behavior differs; source confirms those strings still exist. Endpoint/status-text cleanup remains a documentation followup, not a runtime change in this validation update.
This sign-off update changes documentation only: no firmware/test run, upload, erase, generated assets, source/config changes or commit. The user's existing `platformio.ini` edit is preserved. **Wait for a separately requested 8D.11; no full M3 or unrestricted browser-shell parity is claimed.**
## Second-slice implementation evidence (2026-09-08; before target sign-off)
**8D.10 implementation is complete, host-tested and build-verified, including create/password/generated-value/self workflows and route integration. Target validation and full 8D.10 signoff remain pending; this is not target acceptance. No 8D.11 work.** This supersedes slice 1 scope exclusions and next-work instructions, not its historical evidence. M2 acceptance stands; continuation is not prior-phase target signoff or reserve approval.
### Current contracts
- The admin-only list and single session-bound operation/result slot now support create/password/role/delete, including own-account password/role/delete. HTTPD authorizes/parses/queues; the existing dispatcher executes mutations. No new application task, stack allocation, queue depth, socket or broker client.
- Mutation JSON is **768 bytes / four receive calls**. Exact create schema: `{action, username, role, password}`; password replacement: `{action, username, user_id, auth_generation, password}`. Role/delete schemas are unchanged. Only password accepts JSON escapes; decoded values must be canonical **12-64 printable ASCII bytes**, without trimming. Spaces, quotes and backslashes are valid. Unknown/duplicate/extra fields, malformed encoding and invalid identities reject. Body, parsed credentials and consumed request scratch are wiped on exit.
- Create uses canonical `user_database_create()` duplicate/capacity/NVS policy. `user_database_set_password_current()` shares CLI mutation logic and checks target identity under the mutation mutex before staging, derivation, generation increment and commit-before-live-install. Role/delete retain conditional checks and final-admin/migrated-account protections. Secret-free **96-byte** `{id, action, state}` results add `duplicate` and `full`; results remain replaceable, originating-session-bound, not durable history or retry tokens.
- Credential admission requires one lazily created firmware-lifetime **one-second periodic ESP timer**; create/start failure rejects admission. At/after the **30-second admission deadline**, a tick cancels and wipes the current non-executing pending create/password slot, permitting replacement even while the dispatcher is blocked. Cleanup is nominally deadline plus up to one period **plus scheduling latency**, not a hard real-time guarantee. The callback inspects current ID/deadline, never cancels a replacement early or executing work, and does no database/network work. Role/delete retain dequeue-only expiry; obsolete queued IDs cannot execute replacement work.
- Dequeue marks execution, copies inputs locally and immediately wipes shared credentials/principal/target. Dispatcher revalidates originating session/admin and deadline before database API admission. The timer does not wipe executing local data; credentials are wiped after database return or rejection, and the full local operation at exit. Blocking/derivation/NVS duration is not bounded by the queue deadline. Already-admitted work may complete after logout/expiry; navigation/request abort is not backend cancellation.
- Separate bodyless admin/Origin/CSRF-protected **`POST /api/settings/accounts/generate-password`** calls `user_database_generate_password_value()` and rechecks session currentness before returning one **24-character base64url** value in a **96-byte**, no-store response. It performs no account mutation, slot reservation, NVS commit or revocation. Generated/response scratch is wiped on success and failure. There is **no retained secret or retrieval endpoint**. A lost generation response means nothing was applied; explicit regeneration yields another value. Generation precedes a separate commit, rather than commit-and-retrieve or guaranteed delivery.
- UI generation is explicitly not applied. Submission requires confirmation and, for generated values, saved-password acknowledgement bound to the value and operation/target context. Generated references expire after **60 seconds**, including monotonic admission checks for delayed timers. Edits/context changes invalidate acknowledgement; submission/cancellation, navigation/session/page lifecycle cleanup clears secrets and fences late replies. No routine secret storage/logging/history/clipboard writes. JavaScript/browser-managed copies cannot be securely zeroed; reference clearing is best effort.
- Successful non-create calls immediately best-effort target-revoke web/SSH; create does not notify. Successful role no-op still notifies. Self mutations may close all that account's web/SSH sessions, including this browser's serial/admin routes, before POST/result delivery. No deferred drain, proactive logout or guaranteed receipt. **401/disconnect proves neither success nor failure**: re-login with expected credentials/role and inspect through a surviving authorized route before retrying. Notification failure does not undo persistence; authoritative currentness remains the fail-safe. Ordinary navigation preserves socket/writer ownership; deliberate self revocation is distinct.
- Automatic result checking remains one-second delay, at most ten GETs/15 seconds overall, with manual uncertainty recovery and no automatic mutation retry. **No browser-shell restriction changes:** typed self/generated workflows do not enable those shell commands. SSH keys/raw database or secret export/legacy credential management are outside scope; bootstrap/recovery remain permanently UART0-only.
- The generated-password endpoint is registered independently as an optional route in `web_server.c`, bringing the budget to **23 handlers**, with six sockets/no LRU unchanged. Backend review found only the missing registration, now fixed. Route-agent lifecycle tests cover registration, optional failure isolation and restart; direct-handler tests alone are not registration proof.
### Continuation evidence
- Reported continuation `python3 tests/web_cookie_auth/run.py --accounts`: **PASS, nine Accounts groups plus shared auth/store/IDF adapter groups**. Includes credential parsing, timer creation/start failure, expiry/replacement/execution fences, cleanup, generation without mutation and self revocation. Database/queue/revocation/timer doubles are not target concurrency, flash or TLS proof.
- UI agent reports `python3 tests/web_ui_session/run.py`: **PASS, 57 behavior groups plus renderer/CSP checks**, four added beyond its earlier 53-group slice 2 run. Modeled DOM/fetch/timers/WebSockets do not establish real-browser/backend integration or actual CSP enforcement.
- Route agent reports `python3 tests/web_admin_transport/server_lifecycle.py`: **PASS, 15 groups**, including the corrected independent optional endpoint, failure isolation and restart. UI 57/CSP and lifecycle 15 are agent results; the parent's additional UI/lifecycle reruns have not been reported here and are not claimed as passes.
- Parent reports PASS: `python3 tests/admin_console_boundary/accounts.py`, `python3 tests/admin_console_boundary/run.py`, `python3 tests/web_auth_parse/run.py` (**294 cases**), `python3 tests/web_cookie_auth/run.py --accounts` (**9 plus shared**), `python3 tests/web_cookie_auth/run.py --serial-settings` (**10 groups**), `python3 tests/web_admin_transport/run.py --tickets` (**25 transport / 12 ticket groups**), `python3 tests/web_session_store/run.py --serial`, and diff check.
- Parent `pio run`: **PASS, 25.61 s; 95,908 B RAM / 1,694,237 B flash**. Delta versus slice 1: **+80 B RAM / +9,880 B flash**; versus final 8D.9 UX: **+200 B RAM / +25,400 B flash**. Slice 1 figures below remain historical. Static linker accounting is not runtime heap/stack proof: new timer and descriptor runtime costs, HTTPD/dispatcher stack margins and loaded heap reserves remain unmeasured/unapproved.
- This documentation task performs no source/test edits or build. Results are attributed parent/agent reports, not reruns by this documentation agent. No sanitizer validation, upload, erase, device action, asset regeneration, commit or 8D.11 work is claimed.
### Target checklist (pending)
1. Test desktop/mobile disposable-account create and supplied/generated password workflows: exact bytes, confirmation/context acknowledgement, expiry/cleanup and no application from Generate. Compare UART0 state and persistence after reboot.
2. Exercise other-account password/role/delete with active web/SSH sessions; verify target-only revocation and unrelated web/SSH/native USB continuity, plus navigation socket/writer preservation.
3. With a surviving administrator and UART0 recovery available, test self password/demotion/deletion, final-admin rejection and role no-op. Verify uncertain 401/disconnect handling, expected relogin and inspection before retry; missing result never proves no commit.
4. Mutate/delete/recreate selected targets through CLI and check stale rejection; exercise duplicate/full/protected failures without unintended writes or disclosure.
5. Queue credentials behind a long console prompt; observe deadline cleanup/replacement, logout/expiry, late responses, lost generation/POST acknowledgements and manual recovery without retry. Timer scheduling and executing-secret lifetime require target observation.
6. Verify fourth-route admission, injectable registration/start/stop failures, slow fragmented requests and full serial/admin/SSH/USB mix. Record loaded/cleanup internal/DMA/PSRAM free/min/largest and HTTPD/dispatcher stack margins. Host NVS doubles are not target failure evidence.
7. Obtain target acceptance separately from the completed host-tested/build-verified implementation. **Stop within 8D.10; no 8D.11 authorization or target signoff is inferred.**
## Historical first slice (2026-09-08)
All sections below retain slice 1 evidence, including then-current exclusions, 22-handler count, build and next-slice checklist. They do not describe current slice 2 scope or validation.
**Account list and other-account role/delete implemented, host-tested and build-verified; target validation pending. 8D.10 remains incomplete.** The user requested 8D.10 after the 8D.9 UI refinement. Before editing, selected the plan's explicit split between list/role/delete and create/password/secret delivery. No 8D.8/8D.9 target signoff is inferred from continuation. M2 acceptance stands.
### Available behavior
Settings now has Serial settings and Accounts subviews. Accounts shows at most eight usernames/roles, identifies the current login, and permits confirmed role changes or deletion of another account. Changes persist immediately; there is no Apply/Save staging. Successful operations use the same best-effort targeted web/SSH revocation as CLI commands. Unrelated accounts, UART0, native USB and navigation-related serial writer ownership remain unchanged. A role no-op retains canonical CLI behavior: no database commit, but successful-command target notification still occurs.
The UI submits once, checks acknowledged work automatically (one-second initial/inter-check delay, at most ten GET attempts and a 15-second overall deadline including revalidation), then refreshes the list on known terminal results. It retains failed/uncertain outcome messages. Errors, deadline exhaustion or lost acknowledgement require manual Check Result/Refresh; no automatic mutation retry. Navigating between domains, leaving Settings, logout/expiry/pagehide or changed session identity aborts/fences work and clears the account list/selection labels. Navigation is not backend cancellation. Re-selecting the current domain/view is a no-op.
**Not included:** create, supplied-password replacement, generated-password delivery, own-account changes, SSH keys, bootstrap/recovery, raw database export or any password/verifier/private-key fields. Self role/delete is denied by the server as well as the UI. Existing browser-shell restrictions are unchanged. The second 8D.10 slice must explicitly design create/password and own-account credential/reconnect behavior; this first slice is not full Accounts/password UX or phase acceptance.
## Backend contracts
- `src/web_account_settings.{c,h}` owns one static pending/result slot. It holds only an operation ID, copied originating session/principal, target identity, action/role and deadline/state—no credential material.
- `GET /api/settings/accounts`: current admin cookie policy, no query/body, no-store. Compact projection of username, role, account ID and authentication generation; no SSH key metadata or full database snapshot. `user_database_get_accounts()` copies under the existing mutex with **zero wait**, clearing output on failure. This narrow copy does not change the existing authentication/currentness APIs or claim all authentication paths are nonblocking.
- `POST /api/settings/account-operation`: admin cookie/current principal, strict Origin/CSRF policy; maximum **256 bytes / four receive calls**. Exact ASCII flat schema: `{action, username, user_id, auth_generation}` for delete; plus `role` for role changes. IDs are nonzero unsigned 32-bit decimal integers. Unknown/duplicate fields, escaping/nesting, invalid usernames/enums, fractional/exponential/overflow/coerced numbers and extra fields reject. Unread rejected bodies close; request scratch is wiped.
- `GET /api/settings/account-operation`: bodyless current-admin read of the originating login's retained `{id, action, state}` only. Results have a **96-byte buffer** and states idle/pending/ok/failed/cancelled/stale/protected. Another admitted operation may replace a completed result; this is not durable history or an idempotent retry API.
- `admin_ssh_console_submit_account_settings()` enqueues only the ID, with **zero queue wait**, to the existing four-entry dispatcher queue. The typed union adds no queue-item size and consumes no remote console slot. One pending account operation rejects another with 503/Retry-After. The Serial and Accounts pending slots are separate but executions serialize with each other and UART0/SSH/browser-shell commands on the same dispatcher.
- On dequeue, validate originating session/principal/admin role, reject self-target, and enforce a **30-second admission deadline**. A blocked dispatcher retains the pending slot until it dequeues the request; this is not a slot-release timer or execution timeout. Admitted database work may commit and notify after the initiating login expires. Subsequent stale work rejects. HTTPD never executes account commits or transport notification.
- `user_database_delete_current()` / `user_database_set_role_current()` compare target username/account ID/authentication generation **inside the mutation mutex** before staging or commit. Missing or changed targets return stale without mutating a replacement account. Existing CLI APIs call the same implementation without conditional identity arguments. Final-admin/migrated-admin protections, role generation changes, NVS commit-before-live-install and candidate cleanup remain canonical.
- Only successful database calls trigger target-name web and SSH revocation; notification failures do not roll back a committed account. Authoritative transport currentness remains the fail-safe. Result state reports database completion, not guaranteed notification delivery.
- `web_server.c` optionally registers list GET, result GET, then mutation POST, using the existing failure-safe private adapter. Failure cannot publish mutation without both read routes; failed POST cleanup can leave at most read-only routes. Allocation failure does not disable either terminal transport. No new SDK-private access.
## Validation performed
Final sequential parent run, all passed:
```sh
python3 tests/web_cookie_auth/run.py --accounts
python3 tests/admin_console_boundary/accounts.py
python3 tests/admin_console_boundary/run.py
python3 tests/web_admin_transport/server_lifecycle.py
python3 tests/web_ui_session/run.py
python3 tests/web_admin_transport/run.py --tickets
python3 tests/web_session_store/run.py --serial
pio run
git --no-pager diff --check
```
- Accounts HTTP: **five groups** plus shared cookie/store/IDF getter/adapter regressions. Actual parser/auth/store/handler with deterministic DB/queue/revocation doubles. Covers security/input bounds, max-width eight-account projection, failed list, slot/queue exhaustion, session isolation, obsolete/zero IDs, failure/stale/protected outcomes, deadline/session cancellation, missed account revocation, DB-currentness failure and admitted-work completion after invalidation. A failed database currentness check invalidates that cookie session, so subsequent test operations use a newly issued login.
- Canonical account tests compile production database mutation bodies and compact list getter, plus existing CLI handlers. Verify target generation/deletion/recreation rejection, no-write final-admin protection, unchanged live state and cleared candidate on NVS open/set/commit failure, and existing prompt/currentness/revocation behavior. RTOS/NVS/crypto are deterministic doubles, not actual flash or concurrency tests.
- Dispatcher boundary covers nonblocking Accounts admission and separate Serial/Accounts routing on the existing queue, alongside prior console/certificate/SSH-adapter cases.
- Server lifecycle: **14 groups**, including all three Accounts registration failure positions, failed unregister, restart recovery and unchanged transport isolation/six sockets/no LRU.
- UI: **41 behavior groups** (six new Accounts groups), plus production C renderer/headers/failure and exact inline-loader CSP checks. Covers admin-only list/schema, confirmed typed identity-bound actions, automatic completion/refresh, polling budget/manual recovery, cancellation, failure/uncertainty and session identity/401 isolation. DOM/fetch/timers/WebSockets are modeled; this is not real-browser/backend integration.
- Transport/tickets and session-store/serial integration reruns passed. Earlier in this slice, cookie `--settings`, `--serial-settings`, `--admin`, console `lifecycle.py` and `admin_ssh_policy/run.py` also passed.
Initial test failures were corrected: authored C/JS newline escaping, outdated lifecycle test route/count expectations, and new currentness tests using the existing fake's actual invalidation control. Final results above supersede those intermediate failures. Implementer source/diff review performed; no independent-agent review or sanitizer claim.
## Resources
Final `pio run`: **25.00 s; 95,828 B RAM / 1,684,357 B flash**.
- Versus final 8D.9 UX build (95,708 / 1,668,837): **+120 B RAM / +15,520 B flash**.
- Versus 8D.8 (95,580 / 1,654,529): **+248 / +29,828 B**.
- Static linker totals are not free/min/largest heap, allocation overhead, or stack high-water proof.
- Three additional optional URI descriptors bring the configured handler budget from 19 to **22**. Six HTTPD sockets/no LRU, two serial/one admin WebSockets, dispatcher depth, tasks and stack allocations remain unchanged.
- List response buffer **1,024 bytes**, operation request **256 bytes**, result **96 bytes**, at most eight compact records. Handler/dispatcher stack margins and additional descriptor/name/table runtime heap have not been measured on target. Four bounded receive calls still occupy HTTPD while receiving; full-mix responsiveness needs target validation.
No asset regeneration, upload, erase, device action or commit. Numeric reserves/stack margins remain unapproved; the accepted intermittent full-mix admission issue is unchanged and unresolved.
## Pending validation and handoff
1. Confirm Settings/Accounts desktop/mobile display and role gating on hardware; compare list/roles with UART0. Verify navigation preserves existing serial/admin sockets and writer lease.
2. Change/delete disposable other accounts while they hold web/SSH sessions; verify immediate persistence, only affected-account revocation, and unrelated USB/web/SSH continuity. Reboot and compare persisted state. Do not delete recovery/needed accounts casually.
3. Select an account, mutate or delete/recreate it through CLI, then confirm the stale browser request rejects. Verify self actions are unavailable, and canonical final-admin protection still works.
4. Queue behind a long console prompt; test logout, expiry, queue deadline, busy admission, missed/lost acknowledgement and manual recovery. Check that navigation does not imply cancellation and no mutation is automatically repeated.
5. Test optional route failure/slow fragmented HTTP requests and record loaded/cleanup internal/DMA/PSRAM free/min/largest plus HTTPD/dispatcher stack margins. Host failures are not target NVS-failure evidence.
6. Next is the **second 8D.10 slice**, not 8D.11: create/password workflows, bounded transient secret handling, one-time generated-password delivery and safe own-account changes. Establish the secret-delivery/acknowledgement/revocation contract before editing. Bootstrap/recovery remain permanently UART0-only. First-slice target validation and full 8D.10 signoff remain pending.
-121
View File
@@ -1,121 +0,0 @@
# Phase 8D.11 — SSH authorized keys
## Status (2026-09-08)
Implemented on user request; host-tested and firmware-build verified. Target validation/sign-off remains pending. Accepted 8D.88D.10 and M2 remain accepted; this is not M3 completion or authorization for 8D.12.
## User-supplied target telemetry (2026-09-08)
User supplies a fresh-boot sample at 60 seconds and a later full-client-mix sample. The last browser serial/admin connection again required several attempts. This records partial target evidence, not functional key-management acceptance or phase sign-off; exact flashed revision, browser, key algorithms, traffic duration and cleanup/soak results were not supplied.
| Memory (bytes) | Boot free / minimum / largest | Loaded free / minimum / largest |
|---|---|---|
| Internal 8-bit | 69,584 / 58,076 / 31,744 | 32,596 / 5,468 / 22,528 |
| Internal DMA | 61,828 / 50,320 / 31,744 | 24,840 / 532 / 22,528 |
| PSRAM | 8,246,356 / 8,242,108 / 8,126,464 | 8,087,636 / 8,056,956 / 7,995,392 |
Minima are conservative sums of per-region lifetime minima, not a simultaneous sample; internal/DMA capabilities overlap and must not be added. Low minima are a transient-pressure warning, not proof of allocation failure. Settled loaded free internal/DMA is comparable to the prior accepted 8D.10 sample (32,556 / 24,800), but lifetime minima are substantially lower (previously 19,228 / 11,472). Different connection attempts/workloads prevent attributing that difference to 8D.11.
Boot: SSH/HTTPS started once without reported startup failures; all supplied network request/session counters zero; no broker clients, UART stopped, USB attached but host closed. mDNS announced with ESP_OK. Five accounts/two admins, no cookie sessions. SSH owner stack 20,480 bytes, minimum-free 18,468.
Loaded: two successful SSH public-key sessions (admin console and user serial writer); two browser serial observers, USB observer and active browser admin. Broker writer 16, web observers 17/19, USB observer 10, all pending/events zero at the sample. UART running at 230400 8N1 RTS/CTS, DTR active, RX/TX queues empty; USB host open. Two cookie sessions; mDNS/SSH/HTTPS report operational, no transition/error. SSH minimum-free stack 16,276 bytes; HTTPD/dispatcher stack margins unreported. Public-key authentication works for these two sessions, but algorithms and whether keys were imported through the new UI are unspecified.
Admission evidence: HTTPS handshake attempts logged from 197688 through 379338 ms, with three errors at 377948/378298/378658 ms: `mbedtls_ssl_handshake returned -0x0050`, TLS session creation and HTTPD accept failure. Installed mbedTLS `net_sockets.h` defines this as `MBEDTLS_ERR_NET_CONN_RESET`, not allocation failure. It does not establish why the connection reset or whether transient heap pressure caused delays. The previously accepted intermittent last-browser admission issue has recurred and remains unresolved.
Loaded counters: SSH TCP connections/handshake success/auth attempts 2/2/2; handshake/auth/timeouts/capacity/IO failures zero. SSH broker connect 1, writer request/grant 1/1, revocations 1; admin admission 1/no failures, stream RX/accepted/rejected 7/7/0, TX 5,977, session revocations zero. Browser admin tickets issued/consumed 2/2, connected/disconnected 2/1, TX 272; rejection/capacity/protocol/backpressure/send/queue counters zero. Web requests 89 authenticated/zero auth failures, root 2/status 76/tickets 8/assets 2, response errors zero; tickets issued/consumed/expired 8/5/3. Serial WS connect/disconnect 5/3 with zero admission/service/broker failures; RX zero; TX 218 binary frames/21,200 bytes and 16 control frames/1,322 bytes; writer requests/grants/denials 5/1/4. One send failure/close, zero queue/protocol failures. Cookie login attempts 2, invalid/throttled/capacity/CSRF/logout zero. No broker drop-counter output supplied; zero pending queues is not proof of lossless traffic. Application counters do not account for every pre-HTTP TLS failure.
Documentation-only evidence update: no new build, source/config edits, upload, erase, device commands or sign-off. Next diagnostic evidence should correlate connection attempts with heap/allocation failures and browser network timing/socket occupancy rather than assume heap exhaustion or increase socket capacity blindly. Key-workflow checklist and reserve approval remain open.
## Authorized admission diagnostic slice (2026-09-08)
Separately user-authorized instrumentation for the recurring third-webshell timeout, **not a timeout fix or a key-workflow sign-off**. Implemented in `src/web_diagnostics.{c,h}`, wired through `web_server.c`, `web_console.c`, `console_completion.c` and `src/CMakeLists.txt`. No generated assets, SDK patch, broad HTTPD debug logging, broker/serial change, new task/timer/queue, socket/URI capacity change, timeout change, LRU change or TLS cleanup override.
### Usage and interpretation
Use UART0 (preferred during network stalls) or authenticated admin SSH:
```text
web diagnostics clear
web diagnostics enable
web diagnostics show
```
Enable before reproducing the failed third connection. At failure, run `web diagnostics show` promptly, alongside existing `memory`/`web counters`/browser Network timing evidence; repeat the snapshot if admission remains stalled. Then `web diagnostics disable` freezes event retention (live occupancy continues updating); `web diagnostics show` prints retained history, and `web diagnostics clear` erases history/counters without changing live sockets or enable state. The setting is RAM-only and defaults off after boot. Existing browser-shell policy deliberately denies these commands, even though shared completion offers the fixed forms. There is no HTTP diagnostic endpoint.
- Six connection records are maintained from boot even with capture disabled, so enabling on an already loaded server does not mislabel existing sockets. Each successful TLS connection gets a monotonically increasing, non-wrapping, firmware-lifetime 64-bit `conn` sequence independent of fd, cookie, ticket, broker or user identity. Clear, disable and HTTPS restart do not reset it. Internal TLS object identity is used only during synchronous cleanup; no pointer is exported or dereferenced by the console.
- Snapshot output gives its boot-relative `snapshot_us`, post-TLS occupancy split ordinary/serial-WS/admin-WS and each live fd/connection sequence/open time/age. `kind=0/1/2` means ordinary/serial/admin. Ordinary includes every successful TLS socket not yet observed as upgraded, including idle keep-alives, assets/login/settings and sockets not yet used for HTTP. Classification is actual public `httpd_ws_get_fd_info()` state after upgrade-handler return, **not inferred from `ESP_OK`**. No URI string is inspected.
- The fixed 32-entry overwrite ring records successful TLS open, TLS transport-context close, and enter/result for serial/admin ticket and upgrade handlers. `t_us` is boot-relative observation time; result `dt_us` measures the underlying handler only (including its work/IO, excluding entry resource sampling); close `dt_us` is successful-TLS connection lifetime. Open/enter durations are zero. Open/result occupancy includes the connection; close occupancy excludes it. Event IDs survive clear; `overwritten` counts evicted retained records since clear. `unmatched` counts duplicate create/unmatched close; `lost` counts untrackable creates (getter failure, metadata/sequence exhaustion). Nonzero anomaly counters mean occupancy is not trustworthy as complete evidence. Counters saturate.
- `rc` is the exact handler return, **not HTTP status or ticket issuance outcome**: sending a 401/403/503 can return `ESP_OK`. Match browser HTTP status and existing rejection/issuance counters; there is no ticket-value correlation, request ID, authentication identity, header/body/query logging, or allocation-failure attribution. Upgrade success is visible in the occupancy classification. Ring overwrite or clear/toggle during an operation can leave unpaired enter/result records; do not invent a duration for a missing result.
- Each retained event samples free/largest bytes for internal 8-bit, internal DMA and PSRAM 8-bit, plus the current HTTPD task's minimum-free stack **in ESP-IDF bytes**. Capability scans run outside the diagnostic lock. Samples are sequential, not an atomic heap snapshot; overlapping internal/DMA pools must not be added. Stack watermark includes diagnostic call overhead and is not dispatcher margin. `show` does not query a live task handle: resources are historical event samples, not fresh heap values at show time. Use `memory` for current system heap.
- Console snapshots copy only local diagnostic metadata under a short portMUX, never inspect HTTPD session internals or wait for its owner. Printing occurs outside the lock. At most 32 ID-qualified rows and six live records are printed, even during churn; concurrent clear/overwrite is reported as “no longer retained”. Capture epochs reject samples crossing enable/disable/clear, and sequence checks prevent a stale upgrade result from reclassifying a reused fd. No queued diagnostic work exists, hence no outstanding probe or stale queue lifetime to retire on restart. Minimal connection bookkeeping and upgrade classification remain active while disabled; heap scans and event recording do not. Instrumentation still has CPU/static RAM cost, not zero perturbation.
### SDK audit and explicit blind spots
Audited installed PlatformIO ESP-IDF **5.5.0**, `components/esp_https_server/src/https_server.c` (`httpd_ssl_open`, `httpd_ssl_close`), `components/esp_http_server/src/httpd_sess.c` (`httpd_sess_delete`) and `httpd_main.c`. HTTPS performs synchronous TLS creation before the configured `open_fn` and `user_cb` create callback. It installs a transport-context destructor; that destructor invokes the public close callback before deleting TLS/freeing the context. HTTPD's default close closes the fd, then clears contexts, then frees its session slot. The diagnostic close therefore marks a cleanup observation, not a FIN timestamp or causal close reason. It uses the stored fd, not a getter on an already-closed socket. **Neither `open_fn` nor `close_fn` is replaced**, preserving all existing HTTPS cleanup ownership and failure behavior. Callbacks execute synchronously under the existing HTTPD lifecycle; successful stop finishes cleanup before restart, while failed/partial stop retains remaining live metadata. No asynchronous fd-only events are consumed, avoiding event-delay/fd-reuse ambiguity.
This deliberately bounded first slice does **not** measure TCP connect/accept/listen backlog, pending clients when IDF stops accepting at capacity, handshake begin/duration/failure, TLS allocation failure or aggregate lwIP socket pressure. The public configured open hook is post-TLS too; adding it would not fix these blind spots. No owner-queued client-list probe is added. Occupancy is an owner-published **successful-TLS lower bound**, not the complete HTTPD session table while a handshake is in progress. A clean **6/6** snapshot supports established-connection saturation at that instant (three WS + three ordinary is directly distinguishable); fewer than six does not exonerate admission/TLS/global socket pressure. Correlate time with browser evidence and existing secret-free TLS errors; do not call this pre-TLS tracing or claim the root cause is proven. Existing capacities, receive/send/handshake timeouts and accepted admission issue remain unchanged.
### Diagnostic validation and target follow-up
Focused host harness compiles the entire production module plus the four actual server wrappers with deterministic public-API fakes. Twelve groups cover disabled bookkeeping, routes/return preservation/actual WS state, six-slot saturation, duplicate/stale/reused fd, clear/toggle publication races, full ring/exact overwrite, formatted-output secrecy, bounded show/clear interleaving, stale upgrade completion, partial stop/restart, invalid callbacks/commands and sequence/counter exhaustion; one additional source-guard group checks forbidden APIs and callback wiring. Real TLS/network scheduling, heap/stack values and target stalls are not simulated. Server lifecycle harness additionally checks callback configuration while preserving all 16 existing groups; canonical console lifecycle and policy verify CLI dispatch and unchanged browser restrictions.
Final implementer-run validation (all PASS):
| Command | Evidence |
|---|---|
| `python3 tests/web_diagnostics/run.py` | 12 runtime groups + 1 production integration/secrecy guard group |
| `python3 tests/web_admin_transport/server_lifecycle.py` | 16 lifecycle groups; callback configured, budgets/timeouts preserved |
| `python3 tests/admin_console_boundary/lifecycle.py` | Actual canonical diagnostic dispatch plus existing stop/certificate/reboot cases |
| `python3 tests/admin_ssh_policy/run.py` | Actual IDF parser; four new diagnostic forms denied to WEB, accepted by SSH |
| `python3 tests/admin_console_boundary/run.py` | Shared dispatcher, owner/currentness, completion, deferred certificate and SSH adapter regressions |
| `python3 tests/admin_console_boundary/accounts.py` | Canonical account/key transactions, parser/curve validation, sparse keys and CLI parity |
| `python3 tests/web_cookie_auth/run.py --admin` | Shared cookie policy and combined admin ticket-to-101 integration |
| `python3 tests/web_cookie_auth/run.py --accounts` | Shared cookie policy and existing phase8D11 account/key route regressions |
| `python3 tests/web_admin_transport/run.py --tickets` | 25 transport groups + 12 ticket groups |
| `python3 tests/web_ui_session/run.py` | 68 browser behavior groups + production renderer/HTML/CSP checks |
| `python3 tests/web_session_store/run.py --serial` | Store lifecycle/races and serial/session binding/isolation |
| `pio run` | Final production source build **10.30 s**, **99,156 B RAM / 1,707,597 B flash** |
| `git --no-pager diff --check` | PASS |
Firmware delta versus recorded pre-instrumentation phase8D11 **96,076 B RAM / 1,703,685 B flash**: **+3,080 B RAM / +3,912 B flash**. `xtensa-esp32s3-elf-nm -S .pio/build/esp32-s3-devkitc-1-n16r8/src/web_diagnostics.c.o` verifies **2,816 B ring** (32 × 88), **192 B live table** (6 × 32) and **8 B portMUX**; remaining static metadata/alignment contributes to the build delta. `xtensa-esp32s3-elf-objdump -d` on that same target object gives local entry frames: handler **144 B**, record **144 B**, TLS callback **160 B**, show **528 B**, command **32 B**. These exclude callees and are **not** end-to-end stack margin proof. No task/stack-size increase or diagnostic dynamic allocation. Static RAM is paid even while disabled; this matters against the already low observed internal/DMA minima. Capability scans can perturb enabled timing and memory pressure correlation, so compare with disabled runs rather than treating this instrumentation as free. Initial full build also emitted installed SDK FATFS Kconfig boolean-default notes; all production builds succeeded.
Exact changed-file inventory for this diagnostic task (key implementation untouched):
- Production: `src/web_diagnostics.c`, `src/web_diagnostics.h`, `src/web_server.c`, `src/web_console.c`, `src/console_completion.c`, `src/CMakeLists.txt`.
- Tests: `tests/web_diagnostics/run.py`, `tests/web_diagnostics/fakes.h`, `tests/web_diagnostics/test.c`, `tests/web_diagnostics/README.md`, `tests/web_admin_transport/server_lifecycle.py`, `tests/admin_console_boundary/lifecycle.py`, `tests/admin_ssh_policy/run.py`.
- Docs: `docs/phase8d11_implementation.md`, `docs/agent/current-state.md`, `docs/agent/code-map.md`, `docs/agent/architecture.md`. No change to durable design decisions was needed beyond documenting the new observation module in the architecture/code map.
Target pending: enable before full-mix reproduction; capture both successful and failed third admission with browser status/timing, ordinary-vs-WS occupancy and memory evidence; check live counts on disconnect/stop/restart, disabled capture and clear; retain UART0/USB recovery and one broker writer throughout. Compare enabled/disabled timing and memory under the same load. No new hardware validation, reserve approval, timeout diagnosis or phase sign-off is claimed.
## Scope and contracts
- Accounts settings lists selected-account key types, stable slot indices and SHA256 fingerprints; imports OpenSSH Ed25519/P-256 public-key text and explicitly deletes one slot or clears all keys.
- `user_database.{c,h}` provides a zero-wait fingerprint-only snapshot and conditional key mutation wrappers. Username/account ID/auth generation are checked under the canonical database mutation lock. Existing CLI mutation, persistence, duplicate/capacity, curve validation and SSH authentication semantics are retained.
- `web_account_settings.{c,h}` admits strict JSON and uses the existing account-operation slot and administration dispatcher. Successful mutations best-effort revoke only the target's web/SSH sessions; currentness checks remain authoritative. Self changes can invalidate the response/result login: 401/disconnect proves neither success nor cancellation. Inspect after relogin before retrying.
- Optional **POST `/api/settings/accounts/keys`** accepts `{username,user_id,auth_generation}` and returns `{username,user_id,auth_generation,keys:[{index,type,fingerprint}]}`. This read uses POST to retain the existing bounded JSON/Origin/CSRF policy rather than introduce query parsing. Stale/absent selection returns 409; unavailable snapshot returns 503. No public-key blobs, verifier material or private keys are returned.
- Existing POST `/api/settings/account-operation` adds `key-add` with `public_key`, `key-delete` with `key_index` (02), and `key-clear`. All carry target identity. Request bodies remain **768 bytes/four receives**, public-key text at most **384 decoded bytes**, decoded SSH blob at most **128 bytes**, three keys per account. Envelope/base64/schema rejection occurs at admission; canonical blob/curve validation runs on the dispatcher. Results remain bounded, secret-free, replaceable and session-bound; no mutation auto-retry.
- UI confirms mutations, clears pasted text on submission/context changes, uses text-only rendering and fences stale navigation/session/target work. Completion uses existing bounded polling followed by account/key refresh. Stable key slots may be sparse: option values and deletion confirmation resolve by actual index, not array position.
- `web_server.c` registers the listing route independently through the allocation-safe optional POST helper. **24 handlers**, six sockets/no LRU; no new tasks, stack-size increases, queue-depth changes or broker lease changes. Larger operation/local structures still have runtime stack costs requiring target measurement.
- No private-key upload/export, SSH host-identity management, browser-shell restriction changes, UART0 recovery changes or generated asset regeneration.
## Validation
Parent integrated runs passed:
- `python3 tests/admin_console_boundary/accounts.py`: canonical key validation/authorization, Ed25519/P256, malformed/off-curve/truncated keys, zero-wait snapshots, stale ID/generation/recreation, duplicate/capacity, sparse slots, failed persistence and CLI parity. Crypto host adapters use OpenSSL; this is not target SSH validation.
- `python3 tests/web_cookie_auth/run.py --accounts`: shared authentication policy and account/key route admission/execution tests.
- `python3 tests/web_ui_session/run.py`: initial integrated 64 groups and subsequent sparse-slot production fix, plus renderer/HTML/CSP checks. Final test-only extension independently passed **68 browser groups** (agent-run), including sparse `[1]`/`[0,2]`, deletion/automatic refresh, and invalid/duplicate indices.
- `python3 tests/web_admin_transport/server_lifecycle.py`: **16 groups**, including isolated optional-route failure and restart recovery (dependencies faked).
- `pio run`: final production build **PASS, 14.85 s**, **96,076 B RAM / 1,703,685 B flash**; **+168 B RAM / +9,448 B flash** versus recorded final 8D.10. The later four UI test groups change no firmware source.
- `git diff --check` passed for the integrated production change; the final test extension also passed its scoped check.
Backend agent additionally reports passing console boundary `run.py` and cookie `--serial-settings`. Independent source review identified the sparse-slot UI assumption; corrected production selection/validation and added dedicated regressions. No other concrete review findings were reported. Host tests are not real network/concurrency/stack-margin proof.
## Pending target checklist
1. Import real Ed25519 and P-256 public keys, compare fingerprints with CLI/ssh-keygen, authenticate with corresponding private keys, and verify existing password authentication remains available.
2. Exercise maximum input, malformed/private-key text rejection, duplicates and full three-key capacity. Delete slot 0 while later slots survive; list/delete/clear sparse slots and re-add successfully.
3. Mutate another account through browser and CLI; verify target revocation, unrelated sessions retained, persisted keys after reboot, stale selection rejection and failed-operation recovery.
4. Exercise own-account key changes: immediate revocation and uncertain lost acknowledgement, relogin/inspect without automatic replay. Confirm last-key removal does not imply password removal.
5. Run full supported serial/admin mix, preserving one writer and isolated observers; record heap/internal-DMA largest blocks, HTTPD/dispatcher stack margins and cleanup/repeated-operation behavior.
6. Obtain user target sign-off separately. No upload, erase, hardware test, sanitizer run or commit was performed by this task.
-80
View File
@@ -1,80 +0,0 @@
# Phase 8D.2 Implementation Record
Status (2026-09-05): **Implemented / host-tested / build-verified / target validated by user sign-off.** Post-flash boot and full-client-mix samples are recorded below. Numeric reserve gates remain open. 8D.0 and 8D.1 remain validated by user sign-off. No 8D.3 implementation or browser authentication cutover is claimed.
## Validation Sign-off
The user explicitly marked **8D.2 validated** on 2026-09-05 after supplying post-flash boot and full-client-mix evidence. This closes the phase checkpoint. Unrecorded detailed checks and unexplained observations below remain regression coverage and evidence limitations, not blockers to this sign-off or claims that additional tests were executed. Numeric reserve approval and target validation of future cookie-authentication paths are not implied. Do not begin 8D.3 without a separate request.
## Scope and Behavior
- Started from clean `93eef0e67641f2672c56692a7785e50f31cf236d`; changes remain uncommitted. No unrelated worktree changes were present. No branch import, upload, erase, NVS change, commit or web-asset regeneration.
- Source changes are confined to `web_serial_transport.{c,h}`, `web_session_store.{c,h}` and the existing Basic ticket call in `web_server.c`. Existing `user_console.c` committed-mutation calls and `web_console.c` legacy synchronization calls already reach `web_serial_transport_revoke_user`; no duplicate notifications or command-policy edits were needed.
- Four serial ticket records and two socket slots now retain a `web_session_id_t`, distinct from account ID/authentication generation and transport-slot generation. Nonzero IDs come only from the session store's non-reused 64-bit sequence. Zero explicitly identifies the still-shipped Basic path, not a missing-cookie fallback.
- Trusted internal mint and upgrade interfaces accept an originating ID. Mint checks both session liveness and exact copied principal ownership; consume requires the same originating ID and consumes before currentness validation. A mismatched-session attempt is rejected without consuming the other session's ticket. Basic upgrade cannot consume a bound ticket. All existing serial framing, writer admission, capacities and Basic behavior remain intact.
- Session currentness includes deadline, store readiness, copied-principal binding and authoritative database currentness. Checks run at mint/consume, serial admission boundaries, before input/writer-control dispatch and in the existing 250 ms owner reconciliation. Database calls remain outside both portMUX locks. No CSRF value is exported to the transport.
- `web_serial_transport_revoke_web_session(id)` invalidates that store ID first, wipes only matching tickets and marks only matching reserved/active slots for HTTPD-owned close and existing broker cleanup. Repeated stale cleanup cannot match a newly issued session in a reused slot. HTTPD close-queue failure retains the close flag and retries through the existing owner path; input remains denied.
- Account-name revocation invalidates all matching cookie records, tickets and reserved/active sockets, including after deletion. It invalidates the store even if serial initialization failed. Global revocation invalidates all records without disabling the store and now marks reserved as well as active slots. Basic cache hits continue authoritative DB checks rather than cross-task cache mutation. Username-scoped notifications intentionally cover old and newly recreated identities of that name; other names are untouched.
- A bounded 64-bit transport epoch cancels ticket publication across revocation or detach/re-attach. It never wraps; exhaustion rejects future minting until reboot. As with store issuance, an unrelated concurrent revocation may conservatively reject an in-flight mint, but never removes another session's existing socket. Direct store invalidation/expiry and missed account notifications still fail closed through authoritative checks; no notification is an authorization lease.
- Existing secret-free ticket reject/consume/expiry, store invalidation/rejection, close and queue-failure counters cover these paths. No session ID, cookie, CSRF, digest, password or ticket is added to routine snapshots/logs.
## Resource Accounting
Same release N16R8 environment, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0. Figures compare the final build against the recorded 8D.1 and 8D.0 builds, not a new runtime measurement.
| Resource | 8D.2 | Change From 8D.1 | Cumulative From 8D.0 |
|---|---:|---:|---:|
| Linked static RAM | 95,260 B | +56 B | +728 B |
| Program flash | 1,601,925 B | +1,420 B | +1,952 B |
| Serial ticket array | 384 B, 96 B x 4 | +32 B | +32 B |
| Serial slot array | 3,328 B, 1,664 B x 2 | +16 B | +16 B |
| Transport publication epoch | 8 B | +8 B | +8 B |
| Cookie store and lock | 664 B; records 152 B x 4 | 0 B | +664 B |
Target object symbols (`xtensa-esp32s3-elf-nm -S --size-sort`) and ELF DWARF (`gdb` `sizeof`) verify array/record sizes. The six added IDs plus epoch explain the entire +56 B linked RAM delta. Store `resolve` now survives linker GC through transport currentness, along with principal-binding/account-name invalidation dependencies. Issue/lookup and session-specific logout remain without production HTTP callers; future linkage costs are not included as though all M1 code were live.
- **Heap/PSRAM:** No added dynamic allocation, payload, allocator fallback or per-session heap cost in normal or worst-case execution of these changes. Static internal RAM grows by the reported 56 B. Runtime free/minimum/largest-block deltas are unmeasured, not inferred from static RAM.
- **Tasks/stacks:** No new task or stack-size change. Existing static web transport stack remains 6,144 B; dynamic HTTPD stack remains 10,240 B. Bound-session checks use the existing resolver's 152 B transient record copy; target disassembly reports a 192 B resolver frame, excluding callees. Additional ID/epoch locals use existing stacks. This is not a measured peak-stack delta or approved reserve margin; bound paths are dormant on Basic traffic.
- **Scratch/queues/capacity:** Existing ticket response 96 B and request scratch/payload limits are unchanged. Four one-hour cookie sessions, four 30-second tickets, two serial sockets, one pending TX work item per slot, nine routes, six HTTPS clients and sixteen lwIP descriptors remain unchanged. No TLS buffers or connection limits change. Full-ticket earliest-expiry eviction is deliberately retained until 8D.3's atomic policy cutover.
- Numeric internal-heap/largest-block/stack floors and incremental budgets remain pending. No new floor is invented and no runtime regression is accepted based solely on linked size.
## Executed Validation
- `python3 tests/web_session_store/run.py`: **PASS**, production store public APIs with OpenSSL SHA-256 and deterministic dependency doubles.
- `python3 tests/web_session_store/run.py --serial`: **PASS**, includes the preceding suite plus production serial private ticket/admission/input/currentness/close/broker-cleanup steps with the production store linked separately. Uses `-Wall -Wextra -Werror` and finite compilation/execution timeouts.
- Integration coverage: same-account session-specific versus account-wide cleanup, unaffected account isolation, matching-ID/single-use tickets, all principal fields, stale tickets/principals, expiry, DB failure and direct store invalidation without notification, serial-init failure, close-queue retry, old-session cleanup after slot reuse, stale periodic check after generation change, logout during mint and broker admission, and Basic admission/binary input with the store disabled. The first integration compile caught a misleading-indentation warning in its test double; corrected before passing runs.
- `python3 tests/web_session_store/run.py --serial --sanitize`: **BLOCKED at link**, missing `/usr/lib64/libasan.so.8.0.0` and `/usr/lib64/libubsan.so.1.0.0`; static sanitizer archives are also unavailable. No sanitizer execution/pass is claimed. Optional runner mode remains available on a provisioned host.
- `pio run`: **PASS**, first build 8.05 seconds, final source build 7.92 seconds, each with a 120-second tool timeout; identical resource totals above.
- `git diff --check`: **PASS**. Reviewed source/API diffs for lock ordering, identity mixing, failure isolation, late publication and generation-safe cleanup. No HTTP route or generated-asset change.
- `pio device list`: completed; native device CDC and a USB serial adapter are visible. This changed firmware was not uploaded, and no target/browser test was run. Existing flashed firmware cannot validate this diff.
Host tests are deterministic dependency interleavings, not real multicore scheduling, UART byte-integrity tests or HTTPD network execution. They do not establish the planned one-second expiry-detection target under load, send/close delivery, stack reserve or heap stability. Session-specific logout/expiry via browser cookies remains dormant until 8D.3; no debug route was added to exercise it early.
## User-Provided Target Samples
The user reports flashing 8D.2 and collecting a clean-boot sample, followed by the full client mix. Exact flashed revision/hash, settling/load duration, browser/version/origin and fixture were not supplied. These are sequential user observations, not atomic measurements or agent-executed tests; they supersede the implementation-time statement above that no target run had been supplied.
| Workload | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|---|---|---|---|---|
| Post-flash clean boot, UART stopped, no broker clients | 69,500 / 66,448 / 31,744 B | 61,744 / 58,692 / 31,744 B | 8,223,104 / 8,190,440 / 8,126,464 B | 18,472 B |
| SSH writer + admin SSH + USB observer + two web observers; UART running at 115200 baud | 39,200 / 18,784 / 29,696 B | 31,444 / 11,028 / 29,696 B | 8,138,284 / 8,109,712 / 8,126,464 B | 16,296 B |
- **Both samples:** HTTPS/SSH initialized and running, not transitioning, ports **443/22**, `last-error=ESP_OK`. HTTP Basic via the user database, four users/two admins, unchanged endpoints. SSH role-based password/public-key authentication, shell/PTY-only admission; exec/subsystem/forwarding/SCP/SFTP disabled. SSH owner core **1**, configured stack **20,480 B**. mDNS initialized/announced, `sak-1024.local`, suffix `1024`, `ESP_OK`.
- **Boot state:** SSH/WebSocket sessions **0/2**, web serial attached, zero tickets, no broker clients. UART stopped, owner idle, configuration v1 **115200 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**, RX available/TX pending **0**. Phase 0 commands reported available, not executed. USB initialized/attached, host-open/DTR/RTS **no**, broker disconnected; host line coding **9600 baud, 8N1**.
- **Boot command anomaly:** The transcript contains repeated `ssh sessions` input, one usage response and `0x1 (ERROR)`, followed by successful `ssh sessions` and `ssh status` output. Preserve this as an unexplained console-input/command observation; neither user input error nor a firmware regression is established.
- **Boot counters:** SSH starts **1**, all other supplied SSH counters **0**. Web starts **1**, start-failures/stops **0**; requests total/authenticated/status **10**, root/tickets/assets/auth-failures/response-errors **0**. All ticket/WebSocket session/RX/TX/control/failure counters **0**. Boot includes authenticated status activity.
- **Loaded sessions:** SSH **2/2**, public-key user session **5**, slot **0**, broker **8 writer**; public-key admin session **6**, slot **1**, admin-console route without a broker. Both active/authenticated, admin-command idle, zero output, no RX/TX pending or closing state. Web **2/2**, same-account password-authenticated normal-user observers: slot **0**, fd **56**, generation **1**, broker **10**; slot **1**, fd **57**, generation **1**, broker **11**. No tickets, TX pending or closing state.
- **Loaded broker/USB/UART:** Exactly four clients: SSH **8 writer**, USB **9 observer**, web **10/11 observers**, all with zero pending bytes/events. USB initialized/attached, host-open/DTR/RTS **yes**, broker **9 observer**, host line coding **115200 baud, 8N1**. UART running, owner serial service, unchanged **115200-baud 8N1** configuration, RX available/TX pending **0**, modem **DCD=0, DSR=1, CTS=1, RI=0**, **VLD=1**. Host line coding is diagnostic only, not UART1 configuration authority.
- **Loaded SSH counters:** Starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, failures/disconnects **0**, writer requests/grants **1**, denials **0**, **broker revocations 1**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **19 B**, rejected **0**, TX **24,763 B**, I/O failures/session revocations **0**. The cumulative broker revocation is distinct from session revocation; current SSH writer ownership is confirmed, but the event's cause is not supplied.
- **Loaded web counters:** Starts **1**, start-failures/stops **0**. Requests total/authenticated **72**, root **1**, status **68**, tickets **2**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **2**, rejected/expired **0**. WebSocket connects **2**, disconnects/connect failures/service-start failures/broker failures **0**. RX frames/bytes accepted/rejected **0**. TX **223** binary frames / **33,171 B**, **5** control frames / **398 B**. Writer requests **2**, grants **0**, denials **2**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Writer denials match observer admission.
- **Comparison:** Versus the 8D.1 full-client-mix sample, free internal/DMA heap is **2,220 B lower**, free PSRAM **24,540 B lower**; minimum-free internal/DMA is **600 B lower**, PSRAM **5,316 B lower**. Internal/DMA largest block is **2,048 B smaller**; PSRAM largest block is unchanged. SSH stack minimum-free is **8 B higher**. These are snapshot differences with differing HTTP/TLS activity and unspecified timing, not a controlled incremental allocation measurement or proof of a leak. Cleanup/repetition evidence is still needed for trends; the static link delta remains **+56 B**.
- **Acceptance scope:** Startup, authenticated status and full-client admission/traffic are evidenced, with no reported SSH I/O or web failures. Byte integrity, account-mutation revocation, HTTPS/lifecycle repetition, timed soak/cleanup and full 8D.2 sign-off remain unrecorded. Dormant cookie-bound paths remain host-tested rather than exercised by these Basic-auth samples. Numeric reserves are not approved by these measurements.
## Target Checklist and Handoff
1. After a user-controlled upload, record source/build identity, browser/version/origin and UART configuration. Settle for 60 seconds; collect UART0 `memory`, `web status`, `web counters`, `broker clients`, `usb status`, `ssh status` and `ssh counters`. Keep all internal/DMA/PSRAM free/minimum/largest values; do not sum overlapping DMA/internal measurements.
2. Log in with each role using current Basic auth. Exercise binary serial data and request/release writer, then five explicit Disconnect/Connect cycles per role. Confirm paused reconnect, broker/writer cleanup, two-browser capacity and unaffected observers. Test mDNS and direct IP separately where available.
3. With two browser serial clients for the same disposable account and another account on SSH, mutate password, role and SSH keys, and delete/recreate the disposable account through UART0. Confirm affected existing web sockets/tickets are rejected or closed, unrelated clients survive, and fresh credentials work. Do not change the final administrator or recovery credentials merely to test revocation.
4. Stop/start HTTPS from UART0 and reconnect through Basic; confirm native USB UART1 and user/admin SSH remain usable throughout. Capture status/counters after five bounded restart cycles. Injection of store/transport init or close-queue failure is host-covered only unless a separately controlled target fixture exists.
5. Run a 15-minute concurrent 115200-baud workload with two web clients, USB, user SSH and admin SSH; exercise UART0 recovery and verify traffic/counter integrity. Record memory and SSH stack minimum-free at full load, then disconnect clients/USB DTR, wait 60 seconds and capture cleanup. Compare with 8D.1's comparable workload, not different-baud historical samples; investigate new drops, queue errors, leaks or declining largest-block trends.
6. Record target results and obtain 8D.2 sign-off before stacking 8D.3 runtime changes, unless the user explicitly decides otherwise. Carry browser cookie logout isolation, cross-origin binding, expiry latency and the complete M1 acceptance gate into separately requested **8D.3 only**. No work beyond 8D.2 was performed.
-164
View File
@@ -1,164 +0,0 @@
# Phase 8D.3 — Live browser authentication cutover
Status (2026-09-06): **Implemented / host-tested / build-verified / Phase 8D.3 and M1 validated by explicit user sign-off.** The user closed M1 after successful both-role login, mixed-client operation and the post-soak sample below. This supersedes older blocked/pending/in-progress statements in this record. 8D.08D.2 sign-offs stand; numeric reserve gates remain open. Wait for a separate 8D.4 implementation request.
## M1 validation sign-off and post-soak evidence (2026-09-06)
The user explicitly requested **M1 validation be marked completed**, supplying the following post-soak data after previously confirming both-role HTTPS login. This closes the 8D.3/M1 target checkpoint. Unrecorded detailed acceptance checks remain regression coverage/evidence limitations, **not blockers to this sign-off or claims that those checks were executed**. Numeric reserve approval, later M2 work and target verification of the subsequent admin-SSH empty-line fix are not implied.
### Post-soak sample — clients still connected
| Heap | Free | Minimum-free | Largest block |
|---|---:|---:|---:|
| Internal 8-bit | 38,656 B | 13,756 B | 25,600 B |
| Internal DMA | 30,900 B | 6,000 B | 25,600 B |
| PSRAM | 8,138,320 B | 8,072,744 B | 7,995,392 B |
SSH stack minimum-free remains **16,288 B**, configured 20,480 B. Compared with the earlier mixed-load sample, internal/DMA current free increased by **4,788 B** each and PSRAM free by **49,260 B**; all supplied minimum-free and largest-block values are unchanged. This is not a disconnected/60-second-cleanup sample or proof of leak freedom. DMA overlaps internal heap, and lifetime minima are not synchronized snapshots. No numeric safety floor is inferred from the 6,000-byte DMA minimum.
- UART1 remains running at **115200 8N1/no flow**, owner serial service. RX available 128 B and TX pending zero at its snapshot; modem state DSR/CTS asserted, valid voltage. Four broker clients: user web **8** observer, user SSH **9** sole writer (65 B pending), USB **34** observer and admin web **27** observer. All event queues zero. USB is attached/open with DTR/RTS asserted and host diagnostic coding 115200; changing USB broker ID from earlier 10 to 34 is retained without attributing a specific lifecycle event.
- SSH remains **2/2**, user/admin public-key authentication and broker/admin-console route separation intact. Admin command/output idle, no RX/TX pending or closing for either SSH session. Two successful handshakes/auth attempts; all supplied handshake/auth/I/O/session-revocation/broker failure counters zero. Cumulative stream RX **242 B**, all accepted/none rejected; TX **1,550,114 B**. One historical writer denial is unchanged while current ownership remains SSH.
- HTTPS remains running/ready with last error ESP_OK. Cookie sessions **2/4**, challenges **0/4**, web serial **2/2**, zero outstanding tickets. Both web sockets show TX pending and not closing, consistent with active traffic rather than a cleanup endpoint. mDNS remains announced as `sak-1024.local`, error ESP_OK.
- Web protected requests **510**, authenticated **509**, auth failures **1**; root **4**, status **493**, tickets **4**, assets **8**, response errors **0**. Tickets issued/consumed **4/4**, rejected/expired **0**; serial connects/disconnects **4/2**, no connection/service-start/broker failures.
- Web RX **1 frame / 14 B**, accepted with no rejection. TX **14,670 binary frames / 3,146,808 B**, control **21 frames / 1,813 B**. Writer requests/grants/denials/releases **4/1/3/1**, no revocations. Send/queue/protocol/close-failure-related counters remain zero. No broker-drop counters or independent end-to-end byte-integrity result were supplied.
- Cookie authentication remains **6 password checks**, **3 invalid-credential results**, **1 logout**, and zero throttle/capacity/CSRF-or-Origin rejections. These cumulative counts are unchanged from the earlier successful mixed-client sample; no new login fault is inferred from the historical invalid-credential/request-auth counts.
**Evidence boundaries:** User describes this as after the soak test; exact duration, command/traffic pattern and flashed revision/hash are not explicitly supplied with this sample. Do not silently substitute the recommended 15-minute duration or assert a disconnected cleanup happened. The transcript shows actual continued activity and no reported transport error, watchdog or memory exhaustion. The empty-Enter fix's targeted on-device retest remains unrecorded; this does not reopen M1. No agent upload, device exercise or fresh build was performed to record this sign-off.
**Next:** M1 is complete. Continue only on a separate request for **8D.4 — small transport-neutral console boundary**, preserving the canonical dispatcher, serial ownership and open resource gates. Do not implement later M2 chunks merely because M1 was signed off.
## Successful post-fix target login and mixed-client sample (2026-09-05)
The user explicitly reports successful HTTPS login as both `commander1024` (user) and `admin` after the Origin-mode correction. This supersedes the earlier login-blocker statements below. The following are user-provided sequential snapshots, not agent-executed tests or an atomic measurement. Exact flashed revision/hash and settled duration for this new sample were not supplied.
| Heap | Settled boot free / minimum / largest (B) | Mixed load free / minimum / largest (B) |
|---|---|---|
| Internal 8-bit | 71,204 / 66,752 / 31,744 | 33,868 / 13,756 / 25,600 |
| Internal DMA | 63,448 / 58,996 / 31,744 | 26,112 / 6,000 / 25,600 |
| PSRAM | 8,247,744 / 8,245,836 / 8,126,464 | 8,089,060 / 8,072,744 / 7,995,392 |
| SSH stack minimum-free | 18,464 B (20,480 B configured) | 16,288 B |
**Settled boot:** UART service stopped/owner idle at 115200 8N1/no flow; no broker clients or SSH sessions. USB initialized/attached but host closed/DTR false. HTTPS ready, sessions/challenges/tickets zero, all supplied web and SSH request/traffic/failure counters zero. mDNS initialized/announced as `sak-1024.local`, last error ESP_OK. The USB host's diagnostic 9600 coding does not configure UART1.
**Mixed-client load:** UART running at 115200 8N1/no flow. Four broker clients: web user 8 observer, user SSH 9 **sole writer**, USB 10 observer, web admin 27 observer. Public-key user/admin SSH both active (2/2); admin SSH has no broker client. Two password-authenticated web serial sessions and two cookie sessions, no outstanding challenges/tickets. USB host open/DTR/RTS asserted; diagnostic line coding 115200. RX available/TX pending zero at the serial snapshot; all four displayed broker pending/event queues zero. mDNS remains announced without errors.
- Two consecutive loaded `memory` samples are identical. This is short-term observation, **not** leak/soak/cleanup or reserve-floor validation. Lifetime minima include handshake/earlier activity; DMA overlaps internal heap. The **6,000 B DMA minimum** leaves runtime reserve analysis important even though current DMA free is 26,112 B. No stack fault/watchdog or memory exhaustion is reported.
- SSH: two successful handshakes/auth attempts, no handshake/auth/I/O/session-revocation failures. Stream RX 73 accepted, zero rejected, TX 31,941 B. Broker initial writer request was denied once; later writer snapshot shows SSH owns the lease (no inconsistency inferred from cumulative counters). Admin command-running/output-pending fields were sampled while executing status commands, not proof of a stuck dispatcher.
- Web: 97 protected requests, 96 authenticated and one auth failure; four roots, 80 status, four tickets, eight assets; zero response errors. Four tickets issued/consumed, zero rejected/expired. Four serial connects/two disconnects; two currently active. RX one accepted frame/14 B with no rejection; TX 495 binary frames/43,738 B and 15 control frames/1,255 B. Writer requests four, grants one, denials three, releases one; no revocations. No reported send/queue/protocol/service-start/broker/connection failures.
- Cookie auth: six password checks, three invalid-credential results, zero throttle/capacity/CSRF-or-Origin rejections, one logout, two active sessions. Successful both-role login is explicit user confirmation; the counters also show logout/reconnection activity but do not establish five cycles, account-isolation coverage or logout acknowledgement delivery. The three invalid-credential results and one protected-request auth failure are retained without attributing a cause.
**Reported admin-SSH empty-line issue:** pressing Enter without text prints “Command is restricted to physical UART0.” Source trace identifies `remote_command_allowed()` classifying zero parsed arguments as a policy denial. Corrected that helper to allow empty input to reach IDF's normal quiet `ESP_ERR_INVALID_ARG` handling; existing current-admin/generation checks still run, and `user bootstrap`/`user recover` remain denied. No new dispatcher, route or 8D.4 refactor. `python3 tests/admin_ssh_policy/run.py` passes 15 policy cases with the production helper and installed IDF argument parser, including quoted restricted commands. `pio run` passes in **21.04 seconds**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash versus Origin fix). This SSH fix is **not yet target-tested** and was not present in the user's sample. No upload/erase/commit performed.
Remaining: explicit M1 sign-off, repeated lifecycle/expiry/revocation/isolation and raw-client security checks, timed full-load soak and settled cleanup, numeric reserves and non-SSH owner stack margins. Do not reopen prior phase sign-offs or invent missing execution evidence.
## First target sample and login blocker (historical, user-provided, 2026-09-05)
After clean boot and 60 seconds settled, the user reports:
| Heap | Free | Minimum-free | Largest block |
|---|---:|---:|---:|
| Internal 8-bit | 69,004 B | 66,752 B | 31,744 B |
| Internal DMA | 61,248 B | 58,996 B | 31,744 B |
| PSRAM | 8,223,116 B | 8,218,204 B | 8,126,464 B |
SSH has 0/2 sessions, all supplied error/traffic counters zero, configured stack 20,480 B and minimum-free 18,464 B. HTTPS is running/ready with no lifecycle/response errors, cookie sessions 0/4 and challenges 0/4; eight protected requests were unauthenticated, with zero password-verification attempts, CSRF/origin rejections, logouts, tickets or serial sockets at this sample point. mDNS announces `sak-1024.local`. UART1 is stopped/owner idle, configured 115200 8N1/no flow; no broker clients. USB is initialized/attached but host-open/DTR false, broker disconnected. Its reported 9600 host coding does not configure UART1. These snapshots precede the reported login attempts; they do not establish post-attempt counters. Exact flashed revision/hash was not supplied.
**M1 is blocked:** subsequent login attempts for a normal user and administrator both show “The sign-in challenge expired or the request was rejected. Please try again.” Browser console reports blocked inline scripts with two hashes different from the application's login script hash, denied favicon by default-src, and a denied file URL. No browser login, loaded-memory or full M1 acceptance is claimed. These errors do not establish memory exhaustion or invalid passwords.
The production-renderer suite was rerun and the exact shipped inline-script hash still matches its CSP (`x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo=`). The displayed login message maps to HTTP 403 in the running script; the console's other hashes may be injected-script warnings, not a reason to broaden CSP. Request-stage/status and the bounded error code plus nonsecret Origin/Fetch Metadata are needed to isolate the rejection. No corrective firmware change has yet been made for this target report.
## Confirmed Origin-null diagnosis and correction (2026-09-05)
Follow-up user evidence: `/api/login-challenge` returns 200, `/api/login` returns 403 with request `Origin: null`, `Sec-Fetch-Site: same-origin`, and the pre-login cookie present. `web status` reports ready, zero sessions/challenges/tickets, **zero password-verification attempts** and **seven CSRF/origin rejections**. This confirms the rejection occurs before password authentication; it is not evidence of wrong credentials. No secret values were requested or retained.
Cause: the authored fetch requests used non-CORS `mode: 'same-origin'` under `Referrer-Policy: no-referrer`; browser Origin-header serialization for these POSTs yields `null`. Corrected login fetch options to `mode: 'cors'` and the existing app API helper to use `cors` for POST (ticket/logout), retaining same-origin mode for app GETs. Fetch CORS mode is not permission for cross-origin service access: paths remain fixed same-origin endpoints, credentials remain `same-origin`, redirects remain rejected, CSP `connect-src 'self'` remains intact, and the server's strict Origin/CSRF checks/no-CORS-response policy are unchanged. The login script hash was updated atomically to `eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ=`; existing app loader hash is unchanged because only its external app script changed.
Validation: login renderer/CSP eight Node groups, app nine Node groups, and cookie-policy suite all pass. Node guards assert CORS mode for every mutation (including logout), fixed endpoint destinations and no manually supplied Origin. They do not synthesize real browser Origin headers; Firefox/target retest is still required. `pio run` passed in **14.30 seconds**, **95,508 B RAM / 1,625,705 B flash** (RAM unchanged, flash +16 B versus the preceding live build). No upload/erase. Retest both roles, serial Connect/Disconnect/reconnect and Sign out; expect login POST Origin `https://sak-1024.local` (or the actual direct-IP origin), not null. M1 remains blocked until confirmed on target; other CSP warnings were not loosened or assumed resolved.
## Delivered behavior
- `web_cookie_auth.{c,h}` replaces Basic authentication/cache completely. Both roles use `/login` and the same serial/status application. No admin shell/settings routes were added. Previously cached Basic headers do not authorize a request.
- Four digest-only authenticated sessions retain the existing store's copied principal, canonical-origin binding, CSRF state, non-reused ID and one-hour absolute lifetime. Traffic/polling does not renew expiry. Failure to initialize authentication prevents HTTPS start; UART0/USB/SSH implementations remain unchanged.
- Four 120-second pre-login challenges contain only token/origin digests, CSRF state and expiry. Explicit login bootstrap requires `X-Login-Bootstrap: 1`; a matching live challenge is reused without extending its lifetime or resetting its cookie. Credential submissions consume the challenge, including wrong passwords. A global fixed window permits five password verifications per 60 seconds, including successes. Further attempts return 429 with Retry-After; no HTTPD sleep or per-IP/account table.
- Session and pre-login cookies use `__Host-sak-session` / `__Host-sak-prelogin`, `Secure; HttpOnly; SameSite=Strict; Path=/`, explicit Max-Age 3600/120 and no Domain. A consumed challenge expires its cookie; successful login additionally sets a fresh session cookie. Login with a current authenticated cookie returns 409; account switching requires logout.
- Mutations require canonical same-origin HTTPS Origin and CSRF; upgrade requires Origin and matching cookie/session/ticket. Host case and optional default port 443 normalize; non-443 ports, malformed authorities and IPv6 literals are rejected. Direct-IP and mDNS names remain distinct cookie origins. Cross-site/same-site Fetch Metadata requests are rejected (same-origin/none accepted); absent Origin is permitted only on read/bootstrap requests after Host validation.
- Exactly username/password string fields, maximum 512-byte login JSON, decoded 16/64-byte limits. Unknown/duplicate fields, NUL and malformed Unicode fail. Header/body/request scratch is wiped; rejected unread bodies close instead of invoking HTTPD's automatic body drain. Login reads have a three-second application deadline plus existing socket wait bounds. API authentication responses are at most 512 bytes; safe username JSON encoding is explicit.
- Full live session/challenge/ticket tables reject with 503 and Retry-After 5; serial-ticket earliest-expiry eviction is removed. Expired/stale tickets are reclaimed without database calls under the transport lock. Existing two serial sockets, one-writer broker model and binary protocol are unchanged.
- Logout invalidates its originating session before acknowledgement and requests only its ticket/socket cleanup. Account mutation/revocation continues to invalidate all affected account sessions, not unrelated accounts. Mint/consume/admission/input and existing periodic owner checks remain authoritative if notification fails. Zero session ID no longer falls back to Basic.
- Browser validates `/api/session` before initial connect/reconnect/restore; stores CSRF only in memory; adds Sign out and visible absolute expiry. 401 cancels work/closes local serial/navigates once to `/login`; explicit Disconnect still pauses reconnect without ending login. 403 mutation failures require explicit retry; capacity/backoff and network errors are not bad credentials. Lost logout response is reconciled with session status rather than claiming success. Pending fetch/socket callbacks are generation-guarded. Both authored inline scripts have exact CSP hashes; generated assets were not regenerated.
### Route boundary
| Route | Policy |
|---|---|
| GET `/login` | Public standalone no-store login page, no protected assets |
| GET `/api/login-challenge` | Validated Host, bootstrap header, Fetch Metadata and any supplied Origin |
| POST `/api/login` | Strict Origin, pre-login cookie/CSRF, bounded JSON and throttle |
| GET `/api/session` | Current cookie session; username/role/CSRF/remaining seconds only |
| POST `/api/logout` | Current session, strict Origin/CSRF, empty body |
| GET `/` | Current session; unauthenticated navigation gets 303 `/login` |
| GET five `/assets/` routes; GET `/api/status` | Current session; unauthenticated gets 401, not login HTML |
| POST `/api/ws-ticket` | Current session, strict Origin/CSRF, empty body |
| GET `/ws/serial?ticket=...` | Cookie/Origin authorization and ticket/principal/broker admission before explicit 101 |
No CORS/preflight compatibility or Basic fallback. Query strings outside the exact serial-ticket route and wrong methods are rejected. Error routes have bounded no-store responses. The login document itself also rejects malformed/duplicate cookies; manually corrupted cookies may require clearing those site cookies, unlike ordinary expired well-formed cookies.
## Verified HTTPD boundary and maintenance risk
The delivered solution is **not the previously proposed SDK patch**. `web_httpd_adapter.{c,h}` alone includes installed HTTPD private headers. `src/CMakeLists.txt` supplies private include paths; the adapter fails compilation unless `ESP_IDF_VERSION == 5.5.0`. No installed SDK source was changed and no full component was vendored.
Verified under `/home/mscholz/.platformio/packages/framework-espidf/components/esp_http_server/`:
- `src/httpd_parse.c`, `httpd_req_get_hdr_value_len/str`: return the **first** matching header only. Parsed fields occupy NUL-separated scratch, not a raw CRLF block. Adapter walks that bounded storage and rejects **all duplicate field names**, case-insensitively, plus malformed fields, control characters, Transfer-Encoding and Expect. This is stricter than general HTTP acceptance, deliberately fail-closed. Public getters are called only after validation and with terminator capacity.
- `src/httpd_txrx.c`, `httpd_resp_set_hdr`: appends pointers, does not replace an earlier same-name field. Sending emits each entry; login retains its two cookie values until send returns. Success uses exactly **six of eight additional-header slots**. Tests extract the installed getters and append function rather than inventing their behavior.
- `src/httpd_uri.c`: routes marked `is_websocket=true` send 101 before their handler. The application's serial URI is deliberately registered as an ordinary GET. After cookie/Origin checks, transport consumes the matching ticket and completes currentness/broker admission, then adapter calls `httpd_ws_respond_server_handshake()` and installs the existing transport frame handler. Failed pre-admission never sends 101; handshake/admission failure uses existing reserved-slot/broker cleanup. Tests stub the handshake send: real on-wire integration remains a target gate.
- `src/httpd_txrx.c`, `httpd_unrecv/httpd_recv_pending`: pending bytes are **right-aligned**. The inherited adapter incorrectly wiped the unread suffix. This continuation fixes cleanup to wipe the consumed prefix while preserving unread bytes at the end, or wipe everything when closing. Regression exercises all 0128 pending lengths and partial reads through the installed reader function. This prevents corruption of pipelined requests/early serial frames; it is not a claim of real socket execution.
- HTTPD DEBUG logs include headers, and URI warnings can include ticket queries. HTTPD is compiled with `LOG_LOCAL_LEVEL=ESP_LOG_ERROR`; ERROR sites were inspected for secret-bearing content. This deliberately removes HTTPD warning/debug diagnostics regardless of runtime log-level changes. Application count-only authentication telemetry remains available via `web status`/`web counters`.
Private layout, frame dispatch and scratch ownership must be re-audited for an SDK update, including same-version local source patches (the guard checks the version, not source hashes). Do not distribute private-structure access into other application modules. Wiping reduces request lifetime, not all TLS/allocator/browser copies; do not claim resistance to RAM extraction.
## Resource accounting
Final `pio run` passed in **17.62 seconds** after the cleanup fix:
| Metric | 8D.2 / preparatory baseline | Live 8D.3 | Increment |
|---|---:|---:|---:|
| Linked static RAM | 95,260 B | 95,508 B | +248 B |
| Reported program flash | 1,601,925 B | 1,625,689 B | +23,764 B |
Cumulative versus recorded 8D.0 build (94,532 / 1,599,973 B): **+976 B RAM / +25,716 B flash**. These are linked sizes, not runtime headroom.
- Target object symbol accounting: challenges **576 B (144 × 4)**, counters 32 B, lock 8 B, epoch 8 B, window 8 B, attempts 4 B, ready 1 B: **637 B before placement padding**. Removed Basic cache/key/readiness offset most of this; final link delta includes alignment/other changes. Existing session store remains present.
- No new application task, task-stack size change, module heap allocation, queue, TLS buffer, accepted socket or lwIP descriptor limit. HTTPD URI capacity rises **9 → 14**, with five additional dynamically allocated handler records; HTTPD error handlers use its existing table. Six HTTPS clients and two web serial slots remain unchanged. LRU purge remains enabled; retained-serial admission protection is still an M2 concern.
- Auth request locals include 513 B body/response scratch, 180 B cookie header, token/CSRF/canonical buffers, copied session/principal/challenge/credentials; cookie parsing has nested 1025 B header scratch. No task-stack reserve is inferred from source locals or static link size. Existing HTTPD stack is 10,240 B; real worst-case stack/TLS/PBKDF2/fragmentation measurements remain pending.
- Existing xterm/logo data unchanged. Login page and enlarged authored app are now actually linked; their dormant-preparation flash numbers were not their live cost. Header slots remain eight; login success six, login renderer five.
## Executed validation
All ran successfully in this continuation:
```sh
python3 tests/web_cookie_auth/run.py
python3 tests/web_auth_parse/run.py
python3 tests/web_login_ui/run.py
python3 tests/web_ui_session/run.py
python3 tests/web_session_store/run.py --serial
pio run
```
- Cookie policy suite compiles production store/parser/policy/adapter with OpenSSL SHA-256 and deterministic database/HTTPD doubles. Covers fragmented reads, challenge reuse/consumption/expiry/capacity, session-specific logout, throttle, duplicate headers/cookies, methods/Origin/CSRF/Fetch Metadata, Basic denial, currentness, failures/stop race, cookie header budget and explicit upgrade state. Installed IDF getter/setter/pending-reader functions are extracted verbatim. It does **not** execute the full IDF parser, TLS, URI dispatcher, network handshake or real tasks.
- Parser suite: **268 cases**. Login renderer: production C failure/header checks and **eight Node groups**. Serial app: production C resource/header/CSP checks and **nine Node groups**. Node VM DOM/fetch doubles are not a real browser/CSP/bfcache test.
- Serial integration mode includes store public-API tests plus transport binding/isolation/races and no Basic/no live-ticket eviction. No sanitizer pass is claimed.
No upload, erase, commit, branch change or target/browser exercise was performed. The preceding agent's changes were preserved except the focused pending-buffer fix/tests; its unrecorded executions are not evidence here.
## M1 target acceptance handoff — stop before 8D.4
Use the complete [M1 contract/checklist](phase8d_baseline.md#minimal-m1-browser-contract-planned) and [user acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration). At minimum:
1. Keep UART0 attached. Record flashed revision/configuration and settled-boot `memory`, `web status`, `web counters`, `broker clients`, `ssh status`. Confirm native USB and both SSH roles survive HTTPS stop/start and authentication failures.
2. Test both roles, fresh and previously Basic-authenticated profiles, direct IP and mDNS. Wrong credentials, refresh/back, expiry/reboot, sign out/account switch and lost logout response must remain usable. Verify actual secure cookie attributes and CSP; never include raw cookies/CSRF/tickets/passwords in shared evidence.
3. Five login/serial-disconnect/reconnect/logout cycles per role; five HTTPS stop/start cycles. Check session-specific logout across two sessions of the same account, and account password/role/key changes/deletion/recreation via UART0 while unrelated sessions survive.
4. Challenge/session/ticket capacity without eviction; bounded throttle and retry. Raw-client missing/malformed/duplicate Origin/Host/Cookie/CSRF/content-type/framing tests. Verify an unauthorized or mismatched-ticket upgrade gets **no 101**; validate actual frame routing, early/pending bytes and close cleanup after admitted upgrades. These are especially important for the private adapter.
5. Fifteen-minute full-client mix at 115200 baud (USB, two web serial clients, user SSH and admin SSH), then 60-second cleanup. Record internal/DMA/PSRAM free/minimum/largest block plus SSH stack margins at boot/login/serial/load/cleanup. Check binary integrity, writer isolation, drops and watchdogs. Measure the planned ≤1-second expiry/revocation detection target under contention separately from socket-close delivery.
6. Numeric reserve floors and non-SSH owner-stack instrumentation remain pending. Obtain explicit M1 sign-off before adding the browser admin shell. Do not equate host tests/build success with target acceptance.
-67
View File
@@ -1,67 +0,0 @@
# Phase 8D.4 - Small Console Boundary
Status (2026-09-06): **Implemented / host-tested / build-verified / Phase 8D.4 validated by explicit user sign-off.** The user confirmed successful empty Enter and soak testing after the boot/full-client-mix evidence. M1 and previous phase sign-offs stand. Numeric reserve gates remain open. No 8D.5 implementation or browser admin route is included.
## Validation Sign-Off (2026-09-06)
The user explicitly requested: "Mark Phase 8D.4 as validated - conforming empty enter and the soak test successful". This closes 8D.4 and the previously pending admin-SSH empty-Enter target retest, superseding older pending/in-progress statements in project records.
Soak duration, exact flashed revision, post-soak/cleanup telemetry and individual results for other detailed checklist items were not supplied. These remain evidence limitations, not blockers to this user-approved closure or claims that unreported tests were executed. No new agent build or device operation was performed for sign-off. Numeric reserves remain open. Next is **8D.5 only on a separate implementation request**; M2 as a whole is not yet complete.
## Scope And Contract
Starting revision: `f9ee6eec9cbe06fe5120ee718507eaffd69787a2`, initially clean worktree. Changed production files are `src/admin_ssh_console.{c,h}` and `src/ssh_transport.c`; focused tests are in `tests/admin_console_boundary/`. No console-input changes were necessary because its prompt routing already uses the shared dispatcher.
- `admin_ssh_console_open_owned()` accepts a copied transport-qualified token/principal and an immutable firmware-lifetime owner adapter. Existing SSH admission remains available through `admin_ssh_console_open()`, now implemented beside its owner in `ssh_transport.c`.
- The adapter supplies nonblocking application-output drain checks and deferred lifecycle handling, called by the existing control task outside console locks. SSH validates slot/session/generation and uses existing transport control APIs, never runtime wolfSSH calls from the control task.
- Two console slots remain shared, not two per transport. Occupied and still-executing slots reject replacement. Future admission must coordinate this pool; this phase does not promise simultaneous browser and full SSH admin capacity.
- The token includes transport, slot, session ID and generation. Owners serialize input per session, exclusively consume output, maintain transport authentication/expiry, and close on disconnect/revocation. The core retains admission/dispatch principal checks and queued-work identity checks.
- Completion scratch has a nonblocking claim so different owners cannot race the shared buffer outside the lock. A competing TAB remains unconsumed for retry; stale completion cannot publish into a reused slot.
- Shell exit uses an owner-relative `SELF_CLOSE` action; existing STOP/DISCONNECT/key actions still mean SSH. Unsupported actions fail before side effects. Deferred input is rejected at both admission and feed, and identity is checked again after the final delay.
- The same dispatcher, canonical registry, queue, editor/history/prompts, UART0 policy and output ring remain. Five-second output backpressure and ten-second deferred drain plus 200 ms remain bounded best-effort heuristics, not delivery confirmation.
No new tasks, browser endpoints, UI changes, serial/broker changes, generated assets, persistence changes, uploads, erases or commits.
## Verification And Resources
- `python3 tests/admin_console_boundary/run.py`: PASS for production console and extracted production SSH adapter with deterministic host fakes. Covers readiness/admission, cross-transport and stale identities, completion contention and close/reopen during completion, history, queued disconnect/revocation, UART dispatch, executing cleanup, hidden/visible/cancelled/disconnected prompts, exit-to-SELF_CLOSE routing, unsupported actions, queue rejection, drain timeout/delay, SSH action routing and slow output.
- `python3 tests/admin_ssh_policy/run.py`: PASS, including quiet empty input and physical-only command restrictions.
- Independent code review found no defects. `git diff --check`: PASS.
- `pio run`: PASS in **43.61 seconds**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release. Linked RAM **95,084 B**, flash **1,627,173 B**. Against the recorded latest 8D.3 build (95,508 / 1,625,725 B): **-424 B RAM / +1,448 B flash**. Against recorded 8D.0 (94,532 / 1,599,973 B): **+552 B RAM / +27,200 B flash**. These comparisons use recorded builds, not a fresh baseline rebuild.
Static savings come from removing the full SSH snapshot scratch in the console and reading only the matching published slot under the SSH lock. Added costs are transport tags, owner pointers in sessions/control requests, the immutable adapter, and one completion-claim flag. No payload buffer or capacity increase. Two 4 KiB output rings, four-entry command queue, two-entry control queue, four-line per-session history and task stack sizes (dispatcher 12 KiB, UART frontend 6 KiB, control 4 KiB) are unchanged. No new module heap allocation, socket, TLS connection, HTTP handler, web session or ticket capacity. Runtime heap, PSRAM and stack margins were not measured; no reserve approval is inferred from linked RAM.
Host fakes do not establish actual FreeRTOS scheduling, task-local stdio redirection, real queue capacities, socket behavior or hardware regression. The command runner and lifecycle operations are doubled; direct production helpers test routing and output separately. Sanitizer execution was unavailable because the host lacks the required runtime library, not a sanitizer pass.
## Target Handoff
### User-Provided Boot And Full-Client-Mix Evidence (2026-09-06)
The user supplied UART0 transcripts labeled clean boot and full client mix after the 8D.4 implementation. Exact flashed revision, settling interval, traffic duration and byte-integrity comparison were not supplied. These are user target observations, not agent device execution or phase sign-off.
| Resource (bytes) | Boot free | Boot minimum | Boot largest | Loaded free | Loaded minimum | Loaded largest |
|---|---:|---:|---:|---:|---:|---:|
| Internal 8-bit | 71,512 | 60,344 | 31,744 | 34,632 | 20,648 | 25,600 |
| Internal DMA | 63,756 | 52,588 | 31,744 | 26,876 | 12,892 | 25,600 |
| External PSRAM | 8,247,940 | 8,241,988 | 8,126,464 | 8,089,284 | 8,077,516 | 7,995,392 |
SSH owner stack: 20,480 B configured; minimum-free **18,472 B boot / 16,280 B loaded**. Heap minimum-free values are conservative sums of matching regions' lifetime minima; internal 8-bit and DMA are overlapping capabilities, not additive pools.
- Boot: HTTPS/SSH initialized and running with ESP_OK, zero active SSH/cookie/WebSocket sessions and no broker clients. Startup failure and traffic counters zero. mDNS initialized/announced. UART service stopped with 115200 8N1/no flow configured; USB attached but host closed, DTR/RTS false and no broker client. Diagnostic USB host coding 9600 did not change UART1 configuration.
- Loaded: UART service running at **115200 8N1/no flow** with no pending RX/TX in the sample. Four broker clients: SSH **8 sole writer**, USB **9 observer**, web **10/11 observers**. Two public-key SSH sessions active, one user/broker route and one admin/console route. Admin admission successful, idle, zero output and no RX/TX pending. USB host open with DTR/RTS true.
- Both browser roles logged in successfully: two cookie sessions and two serial WebSockets, no outstanding tickets/challenges. Two login attempts, zero invalid credentials, throttle/capacity/CSRF/Origin rejections or logouts. HTTPS/SSH/mDNS still running/announced with ESP_OK.
- SSH: two successful handshakes, zero handshake/auth/admission/I/O failures and zero admin input backpressure. Stream RX/accepted **13/13 B**, rejected **0**, TX **8,235 B**. Cumulative broker revocations **1** is retained without diagnosis; the current snapshot still shows SSH as sole writer and no session revocations.
- Web: two tickets issued/consumed and two successful WebSocket connects; zero response, service-start, broker, send, queue, protocol or close failures. TX **105 binary frames / 15,760 B**, **5 control frames / 398 B**; no serial RX frames. Both writer requests denied while SSH owns the lease, consistent with observer admission rather than a transport failure.
Compared with the earlier 8D.3 mixed-load sample (not its different post-soak sample), current free internal/DMA memory is **764 B higher** and PSRAM **224 B higher**. Internal/DMA minima are **6,892 B higher** and PSRAM minimum **4,772 B higher**; largest blocks are unchanged. SSH stack minimum-free is **8 B lower**. Different transient histories prevent attributing these runtime deltas solely to the refactor or using them as reserve/leak proof.
These snapshots alone support startup, both-role login and concurrent transport admission/data activity, not detailed console regressions or soak/cleanup. The subsequent explicit sign-off above confirms successful empty Enter and soak testing and closes 8D.4; unreported checklist details remain evidence limitations.
The original target checklist below is retained for regression coverage, not as an outstanding gate to the signed-off phase:
1. Boot and capture UART0 `memory`, status and `ssh status`. Verify UART0 recovery and empty Enter/normal commands through admin SSH (empty Enter is now confirmed by user sign-off).
2. Exercise UART0/admin-SSH serialization, completion/history, visible/hidden/cancelled prompts, and disconnect/revocation while work is queued or a prompt is active. Check reconnect/slot reuse does not receive old output.
3. Exercise slow-reader backpressure and recovery, `exit`, empty-line Ctrl+D, and the existing deferred SSH stop/disconnect/key/reboot paths as appropriate. Confirm acknowledgement remains best effort and stale sessions cannot act on reused identities.
4. Run the always-on browser login/serial explicit disconnect/reconnect, USB UART1 and user/admin SSH smoke. Repeat five serial lifecycle cycles per role and collect comparable settled/full-client-mix/cleanup `memory` and SSH stack telemetry at the supported 115200-baud workload.
No target checks above were executed by the agent. Exact next chunk is **8D.5: bounded admin WebSocket backend**, only when separately requested after the applicable validation decision.
-146
View File
@@ -1,146 +0,0 @@
# Phase 8D.5 — Admin WebSocket backend
Status (2026-09-06): **8D.5 backend implemented / host-tested / build-verified / validated by explicit user sign-off.** The user supplied settled cold-boot telemetry and reported a successful 15-minute full-client-mix active-use soak at 230400 baud, with a few broker drops under heavy output, and explicitly closed 8D.5. Final build: **23.55 s**, **95,580 B RAM / 1,637,273 B flash**. No 8D.6 UI work or M2 acceptance. Prior 8D.4/M1 sign-offs stand; numeric resource reserves remain open.
## Target Sign-Off (2026-09-06)
User reports: "With full client mix, running and active use for 15 mins, soaked, only a few dropped broker packets at 230400 baud with extremely fast and dmesg output. Mark 8D.5 as validated."
This is explicit phase acceptance and supersedes older pending/incomplete validation statements below and in project memory. The reported broker drops are preserved, not treated as zero-drop or byte-integrity evidence; no cause, exact count or affected client was supplied. This 230400-baud workload is distinct from earlier 115200-baud samples. No baud-rate or capacity reduction is made.
Settled cold-boot UART0 measurements supplied with sign-off:
| Heap (bytes) | Free | Minimum-free | Largest block |
|---|---:|---:|---:|
| Internal 8-bit | 70,876 | 59,560 | 31,744 |
| Internal DMA | 63,120 | 51,804 | 31,744 |
| External PSRAM | 8,246,360 | 8,242,140 | 8,126,464 |
SSH owner stack: **20,480 B configured / 18,472 B minimum-free**. Internal/DMA capabilities overlap; their free bytes are not additive. Minimum-free is the firmware's conservative sum of matching heap regions' lifetime minima.
- HTTPS and SSH initialized/running with ESP_OK, each with one successful start and zero startup failures. mDNS initialized/announced with ESP_OK.
- Admin backend initialized/attached with ESP_OK, no active admin socket or tickets, and all admin counters zero. Reported transport static/ticket/PSRAM payload storage **167 / 240 / 1,552 B**, matching implementation accounting.
- No SSH, cookie or serial WebSocket sessions, challenges, tickets or broker clients. Web/SSH traffic/authentication/failure counters zero at boot; this does not describe post-soak counters.
- UART service stopped, RS-232 owner idle, configuration **230400 8N1/no flow**, RX/TX pending zero. USB initialized/attached but host closed with DTR/RTS false and no broker client. Diagnostic 9600 host line coding does not reconfigure UART1.
Exact flashed revision, settling duration, loaded/post-soak/cleanup memory and counters, individual client identities and detailed checklist results were not supplied. The full client mix and 15-minute successful soak are user-reported, not reconstructed from the idle boot sample. Missing details remain evidence limitations, not blockers to user-approved phase closure or claims of unreported test execution. Runtime per-admin-socket cost and numeric reserve approval remain open. No agent build, device operation or source change was performed to record sign-off. **Next is 8D.6 only on a separate request; M2 remains incomplete.**
## Combined backend completion
The user explicitly authorized finishing the entire interrupted 8D.5 implementation, superseding the prerequisite pause below. Extensive uncommitted source/tests were preserved: ticket store, transport, protected server routes, shared-console allocator and SSH mapping, command restrictions, revocation integration, diagnostics, transport regressions, lifecycle harness and local smoke client. This continuation reviewed them, added real authenticated endpoint integration tests and fixed the final admin socket close/reuse race. No upload, erase, commit, generated asset or normal UI change.
### Admission and protocol
- `POST /api/admin/ws-ticket` runs the existing strict cookie/Origin/CSRF mutation policy, then admin-role validation. Two digest-only, non-evicting, single-use tickets expire after 30 seconds and bind the originating session ID and full current password principal. Crypto/database checks are outside critical sections; epoch/generation checks reject stale publication, consumption and prune work.
- `GET /ws/admin?ticket=<64 hex>` is an ordinary HTTP route, not an automatic WebSocket route. Cookie, strict Origin, current admin role, exact ticket shape/consumption, one transport-slot reservation and a free shared console slot precede explicit 101. Upgrade has no CSRF header requirement: the CSRF-protected ticket plus cookie/Origin authorizes it, including browser clients that cannot add custom WebSocket headers. Rejections never execute console commands.
- Exactly one admin socket, two admin tickets and the existing two shared console slots. SSH now retains its allocated console index separately from the physical SSH slot and resolves published owner state by full identity. Busy/executing console slots cannot be replaced. Admin does not join the serial broker or obtain a writer lease.
- Final unfragmented binary frames carry console input (maximum 512 bytes); binary output chunks are at most 1024 bytes. Text, fragmented, oversized and overlapping pending input fail closed. Partially consumed input has a five-second deadline, checked before retry. Consumed input/output and retired payload/console state are wiped. Empty binary frames must not invoke IDF's zero-length header probe twice.
- Saturating lifetime admin counters and allocation sizes are available through `web status`/`web counters`, without token, CSRF, verifier or private-key disclosure. `web clear-counters` does not reset admin counters; diagnostics say so.
### Ownership and failure isolation
- One permanent 20 ms ESP timer schedules at most one HTTPD poll. It does no database, console, payload or socket work. No new application task/stack/dispatcher is created. Blocking HTTPD queue-work configuration makes the optional admin initializer fail closed.
- HTTPD exclusively owns admission, frame input, payload mutation, output sends and session-context cleanup. Revocation/control callers only flag closure and close the generation-qualified console token. Authoritative session/principal checks guard admission, dispatcher execution/prompts, input, output and idle polls. These checks cannot roll back arbitrary already-running commands.
- Detach disables admission/tickets and console access before fencing timer submissions for up to two seconds. Timeout retains the live HTTPD handle and requires a stop retry. Failed SSL stop retains admin ownership; only successful HTTPD stop permits clearing queued-work state and reattachment. Queued polls after detach do no IO; successfully stopped HTTPD cannot execute discarded work.
- **Final lifecycle fix:** installed IDF 5.5.0 `httpd_sess_trigger_close()` queues a raw reusable `sock_db *`. A poll could queue closure, then a frame error free that slot and acceptance reuse it before the queued close executes, potentially closing an unrelated serial client. Admin polling now calls `shutdown(fd, SHUT_RDWR)` directly on HTTPD after checking its session context. HTTPD's next read owns deletion; there is no late queued close pointer. Failed shutdown retries on later polls, with send-failure accounting. This deliberately does not promise a graceful WebSocket close frame or peer delivery. The existing serial transport's use of IDF queued close was not changed; the new admin path cannot introduce this eviction route.
- HTTPS retains six client sockets, now with LRU purge disabled, and grows from 14 to 16 URI handlers. Full socket capacity can delay/refuse new HTTP/TLS connections rather than evict a retained serial writer. Optional admin allocation/registration failure preserves M1 routes and serial attachment; failed optional-ticket unregister leaves an authenticated but unattached/unavailable ticket handler, not a bypass.
- Logout invalidates its cookie session before serial/admin ticket/socket cleanup; account/global revocation follows the same order through the existing `web_serial_transport_revoke_*` integration hooks. Lost notifications still fail session/principal currentness. Unrelated session notifications do not close the admin socket.
### Temporary command restrictions
The parsed canonical command policy rejects unsupported actions before `esp_console_run()`, not after a handler has mutated configuration. From web: only `web status`, `wifi status`, `mdns status`; only bare `user`, `user status`, `user list`, `user show <name>` in the user group; no `reboot`, SSH stop/disconnect/reset or SSH host-key action except `ssh host-key info`. Thus web/network identity changes, all account mutations and one-time generated credentials remain unavailable here until the later lifecycle phase. Ordinary permitted commands, empty Enter and `exit`/empty-line Ctrl+D use the existing dispatcher/editor. Only owner-relative deferred self-close is supported by WEB. UART0 bootstrap/recovery remains physical-only; SSH policy otherwise remains unchanged. See the policy suite for quoted forms.
## Final local validation
All commands below were executed in this continuation and passed. Host compiler warnings are errors; tests are deterministic dependency interleavings, not real multicore execution.
| Command | Actual result |
| --- | --- |
| `python3 tests/web_admin_transport/run.py --tickets` | 19 transport groups plus 12 ticket groups; rerun after shutdown fix |
| `python3 tests/web_admin_transport/server_lifecycle.py` | 11 groups, including all 16 required registration failure positions, two optional positions, failed unregister, failed stop/retry and six-socket/no-LRU configuration |
| `python3 tests/web_cookie_auth/run.py --admin` | Real cookie policy, parser, store, tickets, transport and private adapter linked together; endpoint rejection before 101, cross-session replay burn, admission, isolated logout, missed account revocation, expiry and restart; rerun after fix |
| `python3 tests/web_cookie_auth/run.py` | Existing cookie/HTTPD policy and embedded store regressions pass |
| `python3 tests/admin_console_boundary/run.py` | Shared two-owner allocation, production SSH publication/mapping, queued currentness, prompts, deferred actions, history/completion and wiping pass |
| `python3 tests/admin_ssh_policy/run.py` | SSH policy and browser restrictions using installed IDF parser pass |
| `python3 tests/web_session_store/run.py` | Store API/failure/race suite passes with OpenSSL SHA-256 |
| `python3 tests/web_session_store/run.py --serial` | Serial/session binding, revocation, races and non-eviction regressions pass |
| `python3 tests/web_auth_parse/run.py` | 268 cases, zero failures |
| `python3 tests/web_login_ui/run.py` | C/header/CSP checks and eight browser-behavior groups pass |
| `python3 tests/web_ui_session/run.py` | C/header/CSP checks and nine browser-behavior groups pass |
| `python3 tests/web_admin_transport/client.py --help` | Local import/CLI smoke only; no network/device operation |
| `git diff --check` | Pass |
The combined endpoint harness doubles console execution/IO and the logout revocation hook (matching reviewed production ordering); the console harness separately runs real shared-console code. Installed HTTPD getter/setter/pending-reader functions are extracted, but TLS, handshake writes, actual HTTPD event processing and FreeRTOS are doubled. The server lifecycle harness extracts production lifecycle/table code, not live HTTPD. No sanitizer run/pass is claimed in this continuation; inherited harness notes record missing ASan/UBSan libraries. Manual client offline evidence in its README is inherited, not rerun here beyond `--help`.
### Firmware and resources
**Final build, parent-reported:** the necessary sequential `pio run` after the shutdown fix **passed in 23.55 seconds**, reporting **95,580 B linked RAM / 1,637,273 B flash** on the existing PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release configuration. This verifies the final source, including the shutdown fix. Parent also reports the final independent security integration review found **no actionable findings**. This documentation-only follow-up ran no build or tests.
Historical build: the continuation's one 120-second-bounded `pio run` passed in **22.67 seconds**, at **95,580 B RAM / 1,637,277 B flash**, before the shutdown fix. Both affected production-C host suites passed after the fix; the parent's subsequent final build supersedes that earlier image for final-source verification and saves 4 B flash with unchanged linked RAM.
Final-image deltas (baselines not rebuilt):
| Baseline | RAM delta | Flash delta |
| --- | ---: | ---: |
| 8D.5 prerequisite: 95,164 / 1,628,049 B | +416 B | +9,224 B |
| 8D.4: 95,084 / 1,627,173 B | +496 B | +10,100 B |
| 8D.0: 94,532 / 1,599,973 B | +1,048 B | +37,300 B |
Target ELF/DWARF/map inspection, with no device access: admin payload **1,552 B PSRAM-only** (512 RX + 1024 TX + 16 metadata), slot **80 B**, ticket **96 B** x two, ticket state **232 B** + lock **8 B** = **240 B**. Transport static symbols total **167 B** before placement padding (168 B occupied); retained timer handle is included there. IDF `struct esp_timer` is **32 B**, allocated with internal/8-bit capabilities, excluding heap metadata. Payload/timer persist across HTTPS restarts; PSRAM allocation has no internal fallback. SSH adds two console-index bytes in published state and retains the prerequisite's 80 B principal copies. No added console rings, queue capacities or task stacks. Dynamic TLS/socket/request allocations, heap fragmentation, HTTPD/dispatcher/timer stack margins and internal/DMA reserves remain unmeasured; these static figures are not per-socket runtime cost or reserve approval.
## Target Regression Procedure
No target/network exercise was performed by the agent. The user's target sign-off is recorded above; this original checklist is retained as regression coverage, not as outstanding gates to that closure. Use the already present bounded, stdlib-only `tests/web_admin_transport/client.py`; full usage/security caveats are in its README. It prompts for credentials without echo, keeps cookies only in memory, never logs tickets/CSRF/credential metadata, and attempts logout in `finally`. Prefer trusted certificate/hostname validation; `--insecure` is explicit test-only exposure to active interception, not a local-routing guarantee. Its console output is intentionally raw terminal output: use a trusted target and do not record secret-bearing command output.
```sh
python3 tests/web_admin_transport/client.py --url https://device.local --cafile device-cert.pem --smoke --max-runtime 60
```
1. Have the operator flash the final build-verified image through the usual approved procedure. Record exact revision/diff, clean-boot and 60-second settled `memory`, `web status`, `ssh status` and broker/serial counters. Build verification does not establish target acceptance.
2. Run the smoke client separately with disposable role-user and role-admin accounts. Require user ticket 403; admin cookie/ticket/101, same-ticket replay 403, `help`, empty frame, empty Enter and `exit`, then logout and session 401. Repeat five times per role. No automatic credential retry; respect the five/60-second throttle.
3. Keep two browser serial sockets, USB and role-user SSH at 115200 baud, with a known sole writer; concurrently admit one admin SSH plus web admin. Verify the same broker client IDs/writer before and after web admin open/exit/failure. Attempt a second admin socket and fill both console slots with SSH before web admission: reject, never replace. Fill remaining HTTPS sockets; no serial eviction. Capture live TLS/heap cost rather than infer it from six configured sockets.
4. With a temporary authenticated development client (not a firmware endpoint), test missing/foreign/null/duplicate Origin, missing/duplicate cookie, absent/wrong CSRF on ticket POST, expired/wrong-session/replayed tickets and direct user-role upgrade. Require rejection before any 101. Check raw responses without publishing auth headers or ticket URLs. The supplied smoke client only automates the documented subset, not this full negative matrix.
5. Exercise shared command serialization with UART0 and admin SSH, completion/history, visible/hidden/cancelled prompts, disconnect/revoke/expiry while queued or prompting, and slow input/output. Use disposable secrets and approved existing non-restricted commands; do not type secrets into a retained browser developer-console history. Unsupported web lifecycle/account mutations must report rejection before any state change. The supplied smoke client is not interactive and does not claim prompt/completion coverage.
6. Logout one session with serial+admin; only that session's sockets/tickets close. Change its disposable account from UART0/admin SSH, test deletion/recreation and let a session reach its one-hour absolute expiry. Verify unrelated sessions, queued-command rejection, no stale prompt/output after slot reuse, and no lingering reserved console slot after an executing handler returns.
7. From UART0, repeat five HTTPS stop/start cycles with active admin and pending output; inject detach/queue/SSL-stop failures where feasible, retry stop and ensure no handle reuse until successful stop. Stress simultaneous peer disconnect and new serial admission during admin closure, specifically validating the shutdown/reuse fix. USB and UART0 must remain usable if HTTPS is unavailable.
8. Run at least a 15-minute full-client-mix/slow-reader soak, collect free/minimum/largest internal/DMA/PSRAM and available stack telemetry, disconnect all optional clients, wait 60 seconds and compare cleanup figures. Numeric reserve floors and real per-admin-socket cost still need approval/evidence. Stop before 8D.6; M2 also requires separately requested 8D.6/8D.7 work.
## Historical prerequisite record
The sections below record the earlier prerequisite-only checkpoint. Their no-backend statements and request to pause were superseded by the combined backend authorization/results above; their old build measurements are retained as provenance.
## Scope and provenance
Resumed at revision `e5dce12ed43154dacd086437de0f2d156014d58c` with existing uncommitted prerequisite changes in `src/admin_ssh_console.{c,h}`, `src/ssh_transport.c`, and `tests/admin_console_boundary/`. Preserved and reviewed that work, extended the harness to exercise production SSH snapshot/principal publication and wiping, ran both console suites and the firmware build, and recorded the handoff.
The plan's work-unit review splits the full ticket store, socket owner, HTTP policy/routes and integration tests from this runtime-changing prerequisite. No browser route, ticket store, new task, UI entry, broker client, generated asset, persistence change or device operation is included. No commit or branch change was made.
## Implemented contract
- The immutable console owner adapter now requires `is_current(token, principal)`. It runs on the dispatcher outside console locks, validates full transport identity and originating-session/principal binding, and must not call socket libraries or console handlers. Admission and transport input/output liveness remain owner responsibilities; admission need not already be published to this callback.
- Core checks account and owner currentness for queued work, again immediately before the canonical command runner, and after dispatch. Identity/owner are rechecked after external calls so late validation cannot close a replacement slot. UART0 remains independent.
- Visible and hidden prompts check currentness before publishing and after each wait. Waits poll at 250 ms plus validation/scheduling latency, not a hard real-time deadline; stale semaphore wakes cannot submit a still-waiting prompt. Revoked submitted replies are not returned to handlers. Close wipes prompt input immediately, including submitted input; executing session storage remains reserved until handler cleanup.
- SSH publishes two copied principals under the same lock as its snapshots. Its dispatcher adapter validates active authenticated admin route, transport/session/generation, close intent and full principal binding, without reading owner-task slots or calling wolfSSH. Consuming an external close preserves published close intent until cleanup.
- Reading console output wipes consumed ring segments, including wraparound, while retaining unread output.
These checks do not cancel or roll back arbitrary executing handlers, nor make authorization atomic with subsequent side effects. Database currentness can wait on its mutex. A future browser owner must still enforce cookie-session expiry/logout/revocation at admission, input, output and periodic cleanup; console polling is not a replacement for transport cleanup.
## Executed validation and resources
- `python3 tests/admin_console_boundary/run.py`: PASS. Production console plus extracted production SSH token/publication/adapter code; covers stale owner with current account, unrelated-session isolation, close/reuse during validation, invalidation immediately before execution, revoked/disconnected submitted prompts, unanswered prompt expiry without notification, stale wakes, UART0 recovery, output wiping, published principal cleanup, route/auth/principal/identity rejection and external-close handoff. Existing completion/history, prompts, deferred control and backpressure regressions also pass.
- `python3 tests/admin_ssh_policy/run.py`: PASS, including empty input, ordinary commands and quoted physical-only restrictions.
- Independent static review of the production prerequisite found no actionable defects. Final `git diff --check`: PASS.
- `pio run`: PASS in **38.46 s**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release. **95,164 B linked RAM / 1,628,049 B flash**. Versus recorded 8D.4 (95,084 / 1,627,173): **+80 / +876 B**. Versus recorded 8D.0 (94,532 / 1,599,973): **+632 / +28,076 B**. Baselines were not rebuilt.
- Link map attributes 80 B (`0x50`) to `s_console_principals`. The owner callback adds code/read-only adapter storage, no per-session payload. Two shared console slots, two 4 KiB output rings, four-entry request queue, two-entry control queue, four-line history and task stacks (dispatcher 12 KiB, UART0 6 KiB, control 4 KiB) are unchanged. No new module heap/PSRAM allocation or increased socket/TLS/HTTP handler/session/ticket capacity; actual web-admin socket/slot cost is not yet available.
Host fakes are deterministic, not concurrent: locks are counters, waits use hooks, console execution/lifecycle operations are doubled. Production publication is now exercised, but the complete SSH owner loop, real FreeRTOS scheduling, task-local stdio, socket behavior and runtime memory/stack margins are not proven. No sanitizer or target pass is claimed.
## Target checkpoint and exact next increment
Before stacking the live backend on this runtime-changing prerequisite, obtain target regression or an explicit user decision to proceed under the plan:
1. Boot and capture UART0 status/`memory` and SSH stack telemetry. Confirm admin SSH empty Enter, commands, history/completion, visible/hidden/cancelled prompts and `exit`/Ctrl+D.
2. Disconnect or revoke an admin SSH account with work queued and while a prompt waits; confirm prompt cancellation, dispatcher/UART0 recovery, no reply/output crossover after reconnect, and isolation of unrelated sessions. Only use disposable test accounts and approved mutations; do not publish secrets.
3. Run browser login/serial disconnect/reconnect, native USB UART1 and user/admin SSH smoke at the supported 115200-baud workload. Repeat five serial lifecycle cycles per role and compare settled/full-client-mix/cleanup heap and SSH stack telemetry. Check slow readers do not compromise UART0 recovery.
Next implementation remains **8D.5**, not 8D.6: bounded admin-only digest tickets bound to current cookie session/principal; coordinated admission to the existing two shared console slots; a separate bounded admin transport using HTTPD-owned socket work; full pre-101 Origin/cookie/ticket admission; session/account/global invalidation and authoritative liveness checks; fail-before-side-effect restrictions for unsupported self-affecting commands; counters and focused authenticated integration checks. Review scope and socket/allocation budgets before coding and split further if needed. No normal UI entry or generic HTTP command runner. Admission must never evict a serial client/writer. Test real two-WebSocket coexistence before claiming M2 capacity or acceptance.
-107
View File
@@ -1,107 +0,0 @@
# Phase 8D.6 Implementation
## Status and Scope
Implemented, host-tested, build-verified and **validated by explicit user sign-off on 2026-09-06**. Prior 8D.5 sign-off stands. Independent final re-review confirmed both session-isolation and fit-readiness findings resolved. The sign-off below supersedes historical target-pending notes in this record. Numeric reserves remain open; M2 is not yet complete.
The starting worktree was clean. Production changes are confined to authored `src/web_ui.c`; focused tests extend `tests/web_ui_session/`. No server/protocol/settings/8D.7 policy change, generated asset regeneration, commit, upload or erase. The change fits the work-unit guideline without a preparatory split.
## Target Sign-off and Evidence (2026-09-06)
After providing 60-second fresh-boot, full-client-mix and partial-cleanup telemetry, the user confirmed the remaining validation checks: **"Yepp, that checks all out. Let's wrap up for the next phase."** This closes 8D.6, including the Open admin fix and toolbar-order follow-up. The confirmation covers the discussed switching/lease preservation, console/connection isolation, browser/session recovery, layout and soak/recovery checks. Exact repetition counts, soak duration, browser versions/origin and flashed revision were not separately supplied; do not manufacture those details or treat missing per-case transcripts as blockers to this explicit closure. No target checks were executed by the agent.
| Resource (bytes) | Boot free / minimum / largest | Full mix free / minimum / largest | Partial cleanup free / minimum / largest |
|---|---|---|---|
| Internal 8-bit | 70,988 / 66,536 / 31,744 | 33,900 / 6,516 / 24,576 | 61,296 / 6,516 / 31,744 |
| Internal DMA | 63,232 / 58,780 / 31,744 | 26,144 / 1,580 / 24,576 | 53,540 / 1,580 / 31,744 |
| External PSRAM | 8,246,364 / 8,242,552 / 8,126,464 | 8,087,468 / 8,063,428 / 7,995,392 | 8,186,424 / 8,063,428 / 7,995,392 |
- Boot: HTTPS/SSH/mDNS and both web transports ready without reported startup errors; no sessions/broker clients, UART stopped, USB host closed. Configuration **230400 8N1/no flow**. SSH owner minimum-free stack **18,472 B**, configured 20,480 B.
- Full mix: UART running at **230400 baud**; web broker **24 sole writer**, SSH **9**, second web **10**, USB **11** observers. Two cookie sessions, two serial sockets, browser admin and admin SSH active concurrently. SSH stack minimum-free **16,280 B**.
- Browser admin: five connections/four disconnections, six tickets issued/five consumed, 14 RX/1,096 TX bytes; zero reported capacity, authorization, protocol, input-backpressure, send or queue failures. Serial web: four connections/two disconnections, 47 RX/869,682 binary TX bytes, no transport failures; one expired ticket. HTTPS totals include two starts/one stop, two authentication failures and no response errors. These are cumulative observations, not attribution to particular validation actions.
- SSH: two successful handshakes and **one unexplained handshake failure**; 12 RX bytes, 11 accepted, **one rejected**, 47,392 TX bytes, no I/O failures. Observer input rejection is a possible explanation, not an established diagnosis.
- Cleanup is described as disconnecting all clients/admins **except serial**; only heap data was supplied. It is not a verified zero-client/UART-stopped baseline or a precisely timed cleanup sample. Internal/DMA largest blocks recovered to boot size. A single cleanup sample does not establish leak freedom or exact per-socket cost.
The **6,516 B internal / 1,580 B DMA lifetime minima** remain a resource follow-up for 8D.7/M2. These capabilities overlap and minima are conservative sums across regions, not necessarily simultaneous free-space readings. Do not infer exhaustion, approve numeric reserves or claim an unexplained reserve-floor violation from these values alone. Preserve this evidence for comparable full-load/cleanup measurement in the next phase; it does not reopen user-approved 8D.6.
Toolbar follow-up moved Open/Close admin before the Serial/Admin group, keeping the selector rightmost. Production-renderer/CSP checks, all **17 UI groups**, and `git diff --check` passed; bounded `pio run` passed in **7.60 s**, unchanged **95,580 B RAM / 1,646,489 B flash**. No JavaScript behavior or generated assets changed. This sign-off/handoff update itself is documentation only; no new build, upload or commit.
**Next:** separately requested **8D.7 — Web-shell lifecycle parity and M2 acceptance**. Review and close the explicit unsupported self-affecting command restrictions through bounded deferred owner actions; preserve UART0 recovery, single dispatch, serial isolation and safe credential handling. Split if needed. Do not implement settings or declare M2 complete; no 8D.7 implementation is authorized by this wrap-up.
## Open Admin Bug Follow-up (2026-09-06)
User reports working serial and mode selection, but Open admin displays `Admin connection failed. Open admin to retry.` Source diagnosis: `web_ui.c:openAdmin()` incorrectly validated a 32-character URL-safe serial ticket; `web_admin_tickets.c:web_admin_tickets_issue()` emits 32 random bytes as **64 lowercase hex characters**. A successful admin-ticket POST was rejected locally before `/ws/admin` construction/admission. No Origin, role, shared-console, fit, subprotocol or private-adapter change is needed for this demonstrated failure. Repeated attempts can leave the two unconsumed tickets occupied until their 30-second expiry.
Changed only the admin validator to the backend's emitted format. The UI harness had incorrectly shared the serial fixture with admin; separate 64-hex admin responses now exercise the exact query URL, plus malformed/serial-format rejection and serial isolation. The corrected fixture failed before the source fix (only the serial socket existed), then all **17 UI groups** passed. Production C renderer verifies the unchanged inline loader against the exact reported `sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20=` CSP. The other reported UUID/index.js hashes are not that loader; their source is not established, and CSP was not relaxed.
Final focused reruns: `python3 tests/web_ui_session/run.py`, `python3 tests/web_cookie_auth/run.py --admin` (real store/ticket/endpoint-to-101 admission), `python3 tests/web_admin_transport/run.py --tickets` (19/12 groups), and `python3 tests/web_admin_transport/server_lifecycle.py` (11 groups) all pass. Frontend request behavior and backend admission were tested in separate harnesses, not an end-to-end browser/network session. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged from the previous build. `git diff --check` passes. No assets, uploads, commits or target execution. Parent review and user target retest remain pending; this bug fix is not a new phase or sign-off. On next user-controlled deployment, reload the app and verify explicit Open admin reaches a prompt while serial remains connected; no ticket/cookie/CSRF values are needed in any report.
## Browser Contract
- Validated `/api/session` role reveals Serial/Admin selection only for admins. Ordinary users retain serial-only navigation; existing server authorization remains authoritative.
- Selecting Admin lazily creates one additional xterm instance. Open admin explicitly POSTs `/api/admin/ws-ticket` with the existing CSRF/cookie policy, then connects `/ws/admin`. Selection alone does not open a socket or run a command. Failures and shell termination require explicit reopen, not automatic command/session replay.
- Mode switches only change visibility, focus, selected-terminal sizing and input gating. Both connected sockets continue receiving output. The same serial socket, broker identity and writer lease remain; broker status and Request control/Release control remain visible in both modes.
- Serial and admin have separate 5,000-line scrollbacks and separate 65,536-byte pending xterm-write limits, released through write callbacks. Excess frames are discarded while the socket continues draining, with cumulative per-terminal browser-drop byte counts visible in both modes. The UI explicitly states oldest scrollback lines expire. These counts are not firmware broker-drop telemetry or a byte-integrity guarantee.
- Keyboard/paste goes only to the selected terminal. Serial retains its writer check and 1,024-byte framing; admin sends binary frames of at most 512 bytes with a 4,096-byte input event/socket-buffer admission limit. Excess admin input closes only the shell rather than silently truncating a command. Earlier accepted input cannot be rolled back.
- Close admin, shell `exit` and admin failures do not disconnect serial. Explicit serial Disconnect still closes only serial and pauses its reconnect. Admin open has a 15-second handshake deadline in addition to the existing 15-second API deadline. Capacity/security errors remain safe-text/manual retry; server admission errors during upgrade appear as generic connection failure.
- Logout, session expiry/401 and pagehide cancel pending work and close both routes. Generation guards reject late responses/callbacks; socket handler properties are cleared before close. Resize listeners/observer are removed on exit and restored once for bfcache. Restored pages revalidate the session and preserve serial pause policy; admin requires explicit reopen. At most two terminals and one input subscription each persist for the page lifetime, not one per switch/reconnect.
- Scrollback belongs to the document's first validated username/role/session-stable CSRF tuple, retained separately from the active request CSRF value. Before adopting any later session view, a mismatch hides both terminals, closes both routes, fences pending work and replaces the document at `/`. This also prevents logout from mutating a replacement session. Pagehide hides both terminal hosts and blocks selection until successful same-session validation; same-session restore and mode switches retain scrollback. Failed or delayed restore validation never reveals the retained buffers.
- Terminal bounds are cached only after valid dimensions and successful resize (or an already matching size). An unavailable/invalid measurement gets at most three animation-frame retries per external fit request. Work cancellation removes the pending frame, resets the retry budget and generation-fences stale callbacks; teardown/logout cannot restart readiness retries. A later resize/selection can explicitly start a fresh bounded attempt.
- Existing dark panel/button visual language is retained, selected buttons expose `aria-pressed`, and narrow layouts can scroll rather than clipping the terminal below the dashboard. Real desktop/mobile rendering remains to be checked. The inline loader and its exact CSP hash are unchanged and tested; authored app/document remain no-store.
## Executed Validation
All commands ran successfully in this workspace:
| Command | Result |
|---|---|
| `python3 tests/web_ui_session/run.py` | Production C renderer/header/failure/CSP checks and 16 Node groups pass |
| `python3 tests/web_login_ui/run.py` | C/HTML checks and eight Node groups pass |
| `python3 tests/web_cookie_auth/run.py --admin` | Cookie/session policy plus combined real store/ticket/admin endpoint tests pass |
| `python3 tests/web_auth_parse/run.py` | 268 cases, zero failures |
| `python3 tests/web_session_store/run.py --serial` | Store and serial binding/isolation/races pass |
| `python3 tests/web_admin_transport/run.py --tickets` | 19 transport groups and 12 ticket groups pass |
| `python3 tests/web_admin_transport/server_lifecycle.py` | 11 lifecycle groups pass |
| `python3 tests/admin_console_boundary/run.py` | Shared-owner/currentness/prompt/completion/SSH boundary suites pass |
| `pio run` | Final source build after review fixes succeeds in 21.35 seconds, 120-second finite timeout |
| `git diff --check` | Pass |
New UI checks cover 20 switch cycles with unchanged serial socket/client/writer IDs, hidden binary output and independent terminals, selected input framing, user-only navigation, visible overflow and resumed draining, close/reopen/remote-close isolation, 401/logout/pagehide/expiry, cancelled admin ticket late 401, stale socket callbacks, selected resize, three bfcache listener cleanup cycles and handshake timeout. The harness executes production C-rendered JavaScript with DOM/xterm/WebSocket/timer doubles. It does not execute real terminal escape parsing, hidden prompts, CSS layout, browser CSP enforcement, TLS/HTTPD scheduling, target UART traffic or browser heap profiling.
### Independent Review Fixes (2026-09-06)
Both reported P2 findings are fixed, with parent re-review pending:
- **Cross-session retained scrollback/live admin:** `loadSession` now compares identity before adopting CSRF, role, expiry or view. It requires a clean document on mismatch rather than reusing A's buffers or live admin socket under B's cookie. Regression coverage checks admin-to-admin, admin-to-user, same-account/new-CSRF, username-only and role-only changes, each during active restore, paused restore, live serial reconnect with admin still open, and logout. Tests assert clean `/` replacement, hidden old terminals, both sockets closed, no new ticket/logout request, unchanged old session view and fenced callbacks. Same-identity restore retains both scrollbacks and live same-session reconnect retains admin.
- **Failed initial fit cached forever:** Valid measurements alone populate the bounds cache; three bounded readiness retries recover from an initially undefined measurement at identical host bounds. Tests also exhaust retries, verify later external retry, and invoke stale callbacks after pagehide/restore, expiry and logout. The first test run exposed a fresh fit scheduled by logout's session check; suppressing fits while logging out fixed that case before the final passing rerun/build.
After the fixes, reran `tests/web_ui_session/run.py` (16 groups), `tests/web_login_ui/run.py` (8 groups), `tests/web_cookie_auth/run.py --admin`, `tests/web_session_store/run.py --serial` and `tests/web_auth_parse/run.py` (268 cases), all with `python3`, all passing. The other unchanged-backend suites in the table passed during initial implementation and were not rerun for these UI-only review fixes. Final `pio run` and `git diff --check` pass. No target/browser result, phase sign-off or M2 completion is claimed.
## Resource Accounting
Final PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0 N16R8 release build:
| Metric | Final | Versus signed-off 8D.5 build | Versus recorded 8D.0 |
|---|---:|---:|---:|
| Linked RAM | 95,580 B | 0 B | +1,048 B |
| Flash | 1,646,489 B | +9,216 B | +46,516 B |
The delta is authored HTML/CSS/JavaScript read-only content. Review fixes add **0 B RAM / 1,376 B flash** versus the initial 8D.6 build (22.22 seconds, 95,580 / 1,645,113 B). An earlier pre-final-CSS build passed in 25.30 seconds at 95,580 / 1,644,905 B. The table is the final post-review-fix source result. Browser-only additions are one retained session identity tuple and a retry counter using the existing single animation-frame slot, not new firmware storage or a polling task.
No firmware runtime allocations, tasks/stacks, queues, request scratch, route count or capacities are added. Existing six HTTPS socket slots, two serial sockets, one optional admin socket, two admin tickets and two shared console slots are unchanged. The UI now exercises the already-allocated backend with simultaneous serial/admin WebSockets plus periodic HTTPS requests; actual loaded TLS/socket/console heap headroom still requires target measurement. There is no LRU eviction/capacity increase to make the selector work.
Browser cost adds a lazy terminal/fit addon and 5,000 lines of scrollback, plus at most 64 KiB pending output payload per terminal (128 KiB together) and callback/object overhead. This is not a total browser heap bound: xterm cell storage depends on dimensions, browser networking queues are implementation-owned, and JavaScript cannot securely wipe engine-managed strings. Numeric internal/DMA/largest-block/stack reserve gates remain open.
## Target Checklist (Retained for Regression)
The user sign-off above closes 8D.6. This original checklist is retained for future regression coverage; individual unrecorded details are evidence limitations, not outstanding phase gates.
1. Record exact flashed revision, browser versions and direct-IP/mDNS origin. Verify user serial-only login and admin selector on desktop and narrow/mobile screens, selected styling/focus, resize/orientation and usable terminal height. Exercise initially unavailable font/cell measurements: sizing must recover at unchanged bounds or after a later explicit resize, without unbounded retries.
2. With serial writer active and sustained UART output, perform 20 Serial/Admin switches. Confirm unchanged broker client/writer IDs, Request/Release from both modes, continued hidden output and no admin keystrokes on UART1. Repeat as observer. Distinguish browser-drop counts, firmware broker drops and expected scrollback rollover.
3. Exercise admin empty Enter, normal commands, history, Tab completion, Ctrl+C and hidden prompts while switching views. Confirm no input/output/prompt crossover with UART0/admin SSH. Close/reopen five times and use `exit`/empty Ctrl+D; serial must remain intact. Explicit serial Disconnect/reconnect must not close admin.
4. Test admin capacity rejection with existing console/socket occupancy, network failure, stale ticket and repeated manual reopen; retained serial must not be evicted. Confirm existing 8D.5 unsupported self-affecting command restrictions still reject without side effects; no 8D.7 parity is claimed.
5. Test logout, account revocation, absolute expiry, pagehide/bfcache restore, ordinary reload and interrupted logout with both routes open and with a pending ticket. No stale admin command/session replay; expired auth returns to usable login. Recheck unrelated-session isolation. Change the same-origin cookie via another tab/account login (admin-to-admin and admin-to-user), then restore/reconnect: no old scrollback may appear in the new document, and old admin must close. Same-session restore must retain both scrollbacks; delayed/failed revalidation must leave them hidden.
6. Run UART0 recovery, USB UART1, user/admin SSH and two-browser serial plus one browser-admin full mix with HTTPS polling. Capture `memory`, transport/broker counters and SSH stack margins at settled boot, serial-only, serial+admin, full load and after five open/close/logout cycles. Compare internal/DMA/PSRAM free/minimum/largest blocks without inventing reserve floors.
7. Run a bounded 15-minute mixed-client soak at the user's supported workload (record actual baud; prior 8D.5 was 230400), then disconnect and collect 60-second cleanup telemetry. Check no watchdog/stack faults, monotonic leaks, declining largest-block trend or unexplained lease loss. Profile browser memory across repeated switches and output overflow where feasible.
8D.6 is closed by user sign-off above. Wait for a separate **8D.7** implementation request; numeric reserves and the M2 acceptance milestone remain open.
-154
View File
@@ -1,154 +0,0 @@
# Phase 8D.7 — Web-shell lifecycle parity and M2 acceptance
## Current status: implemented scope validated, M2 signed off
**8D.7 validated and M2 explicitly signed off by the user on 2026-09-07 for the implemented scope.** After "Ok, mark 8D.7 as validated", the user explicitly requested: "Jupp, sign M2 off" following discussion that 8D.8 read-only settings is next. This supersedes all earlier M2-open, target-pending, acceptance-blocking and continuation statements below. M2 is accepted without requiring revalidation; full browser command parity and individual unreported checklist passes are not asserted.
### User sign-off and evidence (2026-09-07)
- User reports thorough testing with no further defects except intermittent admission failures in the supported two serial WebSockets plus one admin WebSocket arrangement. These have recently not recurred and are accepted as nonblocking for this sign-off, not diagnosed or fixed. The earlier admission investigation and telemetry below remain historical evidence and a follow-up if the issue recurs.
- User verified certificate rotation and web start/stop, with lifecycle testing through UART0, SSH admin and web admin. After stopping web from the browser, restart was through another administration route; this is not a claim that browser `web start` is enabled.
- User reports full-client-mix operation with no broker drops at rates up to **230400 baud**, with the external adapter baud corrected. This is user-reported workload evidence, not a universal zero-drop guarantee or a new measurement inferred from the earlier boot sample.
- Exact flashed revision, repetition counts, soak duration, reboot-specific results and individual account-mutation/injection checklist results were not separately supplied. Unrecorded details remain evidence limitations and regression coverage, not claims of execution or blockers reopening this user-approved validation.
**Deferred scope:** browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain in force until separately implemented; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves and stack margins are not approved and remain follow-ups, not blockers reopening accepted M2. **Next is 8D.8: read-only settings entry and Serial page, only when separately requested. This sign-off alone authorizes no implementation.** This sign-off changes documentation only, with no production/test edits, build, device operation or commit.
## Third bounded account slice history
The following slice reviews, investigation and checklists preserve evidence as recorded before sign-off. Their target-pending, acceptance-blocking and next-slice instructions are superseded by the current status above; checklist items are not individually promoted to passed.
**Account slice reviewed / host-tested / build-verified (2026-09-07); no actionable findings remain. Target validation and 8D.7/M2 acceptance remain pending.** This supersedes the account restrictions and next-slice statements in the historical sections below, not their recorded validation limits.
Browser admins may now operate on other accounts with exact parsed `user add <name> user|admin`, `user password <name>`, `user delete <name> --force`, and `user role <name> user|admin --force`. Add/password use hidden password and confirmation prompts. Dispatcher policy and the canonical handler both enforce this bounded allowlist. Self-target mutations (even no-op role changes), generated-password output, key workflows, bootstrap and recovery remain blocked for browsers. Existing inspection is unchanged. Database final-admin and migrated-account protections remain authoritative.
### Operation-admission currentness
The guarantee is **operation-admission currentness**, not currentness at commit. Account, originating owner/session and console token are checked after password prompts and before admitting the database API operation; delete/role also revalidate before API admission. Queued or subsequent stale operations must reject. Disconnect, logout, expiry or revocation during password derivation does not cancel or roll back an already admitted operation. Such an operation may persist and request target-only web/SSH revocation after the initiating browser is gone. Notification failure does not undo a committed mutation, and completion output is not a receipt guarantee.
After a disconnect with an uncertain result, reconnect using a current administrator and inspect `user list` / `user show <name>` before retrying. Password values cannot be inspected; verify the intended login outcome or deliberately reset the target password rather than assuming cancellation. Do not expose credentials in status or logs.
### Review and verification
Independent production and regression reviews found no actionable defects. The earlier proposed precommit-currentness finding was withdrawn as inconsistent with the existing executing-handler contract. No production changes were needed during this review continuation; inherited changes were preserved.
Passed in this continuation: `python3 tests/admin_console_boundary/accounts.py`, `python3 tests/admin_console_boundary/run.py`, `python3 tests/admin_ssh_policy/run.py` (review agent); `python3 tests/admin_console_boundary/lifecycle.py`, `python3 tests/web_admin_transport/run.py --tickets` (25 transport / 12 ticket groups), and `python3 tests/web_session_store/run.py --serial` (parent). Account regressions cover admitted derivation invalidation, successful commits and target-only notification arguments, NVS open/write/commit failures, candidate cleanup, and rejection of the next stale operation.
These are deterministic handler/database and separate console/transport boundary tests, not real HTTPD disconnect/expiry, scheduler or end-to-end socket tests. Account tests double currentness, prompts, crypto, NVS and notification APIs; they do not independently prove live connection isolation. No device or sanitizer validation was performed.
`pio run` passed (incremental, 3.35 s): **95,580 B linked RAM / 1,648,577 B flash**, **0 / +516 B** versus the recorded certificate slice, **0 / +2,088 B** versus 8D.6, **+1,048 / +48,604 B** versus 8D.0. No new task, queue depth, route, socket capacity, assets or stack size; runtime reserves and stack margins remain unmeasured.
### User target evidence: admission issue (2026-09-07)
User supplied a 60-second fresh-boot sample, then reported intermittent full-client-mix admission failures. Exact flashed revision, browser/tab arrangement, failed-request timing and loaded duration were not supplied. This is target evidence, not full-mix acceptance or proof of an account-slice regression.
| Heap (bytes) | Boot free / minimum / largest | Eventually connected mix free / minimum / largest |
|---|---|---|
| Internal 8-bit | 70,860 / 59,548 / 31,744 | 33,428 / 5,280 / 23,552 |
| Internal DMA | 63,104 / 51,792 / 31,744 | 25,672 / 344 / 23,552 |
| PSRAM | 8,246,176 / 8,240,232 / 8,126,464 | 8,084,100 / 8,058,464 / 7,995,392 |
Boot: HTTPS/SSH running without reported startup errors, mDNS announced, four accounts/two admins, no cookie sessions or web/SSH clients, UART stopped at configured 230400 baud, broker empty, USB attached with host closed. SSH minimum-free stack 18,472 B. Admin backend initialized/attached, 167 B static / 240 B ticket storage / 1,552 B PSRAM payload. Supplied boot request/connection/failure counters were zero.
User could promptly connect SSH writer/admin, USB observer and browser serial; two web observers were possible, but adding browser admin repeatedly timed out. Disconnecting one observer allowed admin admission; the last observer eventually reconnected. UART logs include repeated TLS handshakes and errors `-0x0050` (also on write) and `-0x7280`. No loaded counters or failed-allocation trace supplied. DMA's 344 B lifetime minimum is concerning, but capability heaps overlap and regional minima are conservative sums, not proof of an allocation failure or simultaneous remaining reserve.
Installed IDF 5.5.0 / Mbed TLS 3.6.3 source identifies these errors as connection reset and connection EOF, respectively, not allocation errors. Source investigation found six shared HTTPD client slots with LRU disabled (`src/web_server.c`); IDF stops selecting the listener when full. Ordinary idle HTTP connections can retain slots: the one-second receive wait is not an idle lifetime. TLS handshakes run synchronously on HTTPD with a configured five-second handshake timeout. Browser assets, session/status/ticket requests and WebSockets compete for slots. TLS already uses external-memory allocation; increasing sockets or enabling LRU blindly would risk memory pressure or retained serial sessions.
**Investigation remains open:** transient HTTPD slot occupancy is the leading hypothesis, with handshake blocking, internal-memory pressure and global descriptor capacity possible contributors. Disconnecting an observer frees both a socket and resources, so it does not isolate the cause. Next evidence: identify ticket POST versus WebSocket-open timeout using redacted browser timings/status; correlate count-only HTTPD ordinary/WS occupancy and allocation failures; repeat with serial traffic paused. Do not share cookies, CSRF values, ticket URLs or unredacted HAR. No runtime fix, device operation or build performed for this investigation; full-mix reliability must be resolved before acceptance.
### Historical pending account target checks
1. Exercise each enabled other-account command, hidden confirmation/mismatch/cancel, and self/generated/key/bootstrap/recovery rejection. Verify final-admin protections and unchanged UART0/admin SSH behavior.
2. Confirm successful target mutations revoke only that account's web/SSH sessions, retaining unrelated browser serial/admin, USB and SSH traffic. Check stale queued commands and prompts after logout/expiry/revocation and slot reuse.
3. Where safely injectable, disconnect during admitted derivation and inspect the resulting account state after reconnect; do not expect cancellation. Check persistence failure and retry recovery without logging secrets. Unavailable timing/failure injection remains unexecuted.
4. Run the lifecycle/full-mix/soak/resource checklist below as well. Remaining self/generated/key/legacy-credential and other owner-specific parity work stays blocked until separately implemented; this review does not close M2 or start another slice.
## Second bounded certificate slice history
**Second slice implemented / host-tested / build-verified; independent reviewer reports no actionable findings. No hardware validation or 8D.7/M2 acceptance.** The user explicitly authorized continuing to stack the next bounded slice; this is a continuation decision, not target sign-off. Next comes credential/account handling, then other owner-specific slices. Prior 8D.6 sign-off stands; numeric reserves remain open.
The second slice enables only exact parsed browser **`web certificate rotate --force`**. It changes the shared console boundary, WEB owner and canonical web handler (`src/admin_ssh_console.{c,h}`, `src/web_admin_transport.c`, `src/web_console.c`) plus focused tests. No new task, queue depth, route, socket/session/ticket capacity, asset, UI/settings feature or stack size. SSH and UART0 certificate/lifecycle behavior is unchanged. This documentation handoff does not change source/tests or run a build/device operation.
### Second-slice execution contract
- Policy uses the same argument parser as the canonical console: exactly four parsed arguments are required; quoted equivalents are accepted, missing `--force` or extra arguments are not. The browser handler schedules a typed action before certificate side effects, not a command string for later replay.
- The existing request queue uses a command-line/deferred-action union, preserving capacity. The immutable owner's `dispatcher_actions` mask selects certificate rotation for the existing **12 KiB dispatcher**, not the **4 KiB control task**. Other existing actions retain control-task execution; a zero mask preserves SSH behavior.
- Control waits up to ten seconds for application-buffer drain, cancels if it does not drain, then delays **200 ms** and attempts a nonblocking dispatcher handoff. A full queue fails before mutation. Pending input gating persists through queue residence and execution, not just handoff. This bounds the acknowledgement/drain stage, **not browser receipt, dispatcher queue latency, or certificate/NVS/stop/start execution time**; another command or prompt can delay the dispatcher.
- Dispatcher revalidates token/principal/originating session, verifies owner and pending state, reserves the slot as executing and rechecks currentness before the owner callback. WEB revalidates again before mutation. Self-detach cannot reuse the executing slot; it is wiped/released after return. Stale/revoked work fails before side effects; these checks cannot roll back an already executing action.
- WEB calls transactional `web_security_rotate_certificate()``web_server_stop()``web_server_start()`, outside console locks and without socket IO or handler replay. Generation/key generation/persistence failure leaves the previous committed/live material and skips stop/start. Successful commit installs the new identity before stop; later lifecycle failure does not undo it. A stop error returns immediately without start, retaining HTTPD ownership under existing server rules; auth may already be invalidated and transports detached. Start failure requires UART0/admin-SSH recovery.
- Successful restart closes both browser routes, invalidates old sessions/tickets and removes any web writer lease. Reconnect requires verifying/trusting the new certificate and logging in again. No account password or legacy recovery credential is rotated; USB UART1, UART0 and SSH are not stopped. Completion/error output after self-detach is not guaranteed to reach the browser.
### Second-slice reported verification and resources
The implementer reports PASS for `python3 tests/admin_console_boundary/run.py` (including `certificate.c`), `python3 tests/admin_console_boundary/lifecycle.py`, `python3 tests/admin_ssh_policy/run.py`, `python3 tests/web_admin_transport/run.py --tickets` (**25 transport / 12 ticket groups**), `python3 tests/web_admin_transport/server_lifecycle.py` (**11 groups**), `python3 tests/web_cookie_auth/run.py --admin`, `python3 tests/web_session_store/run.py --serial`, and `git diff --check`. Independent review found no actionable findings. Sanitizer validation is unavailable because `libasan`/`libubsan` are missing; no sanitizer pass is claimed. Host harnesses do not prove concurrent RTOS/HTTPD/TLS behavior or target stack margins.
Parent reports final **`pio run` PASS in 26.32 s: 95,580 B linked RAM / 1,648,061 B flash**. Deltas: **0 / +1,036 B** versus first slice, **0 / +1,572 B** versus 8D.6, **+1,048 / +48,088 B** versus 8D.0. Baselines were not rebuilt. The owner mask and local deferred-request/principal scratch are real accounting considerations despite unchanged linked RAM and queue capacity; host `sizeof` is not target layout or stack-margin proof. Control/dispatcher path high-water marks remain unknown. No new tasks, queue depths, routes, assets or stacks; existing HTTPD/SSH stacks and transport capacities remain unchanged.
## First-slice history (2026-09-06)
The following behavior, verification and resource figures record the first slice, not the second-slice final build.
**First increment implemented / host-tested / build-verified; target validation pending. 8D.7 and M2 are not complete.** Prior 8D.6 user sign-off stands. The user requested starting 8D.7; scope review splits the several distinct lifecycle/identity/network/account paths as the plan permits.
This increment enables **browser-admin `reboot` and `web stop`** using the existing deferred-control task. Allowed production files are `src/admin_ssh_console.{c,h}`, `src/web_admin_transport.c`, `src/web_console.c` and `src/system_console.c`; related host tests and documentation are updated. Starting worktree was clean. No new task, queue, socket, route, payload capacity, generated asset, UI/settings feature, upload, erase or commit.
## Implemented behavior
- The canonical `web stop` handler recognizes browser-origin dispatch through `admin_ssh_console_dispatch_is_web()` and queues `ADMIN_CONSOLE_DEFER_WEB_STOP` before any service side effect. UART0 and admin SSH keep their existing synchronous HTTPS-stop path.
- Browser `reboot` now passes parsed command policy and uses the existing canonical remote-reboot deferral. The acknowledgement says console output rather than SSH output. UART0 remains synchronous; SSH reboot remains deferred.
- In the first slice, WEB owner supports SELF_CLOSE, REBOOT and WEB_STOP only. Its control-task callback revalidates originating cookie-session/principal binding and full console identity after the drain delay, outside console locks. It calls lifecycle APIs, not socket IO; HTTPD remains the socket owner. Stop API errors propagate through the existing deferred-result path. Revoked/stale work does not invoke stop/reboot.
- Existing two-entry control queue, ten-second drain wait, 200 ms final delay and five-second output backpressure remain. Drain is a best-effort application-buffer heuristic, **not browser receipt confirmation**, and the drain bound does not establish a hard upper bound on underlying lifecycle API execution. A failed stop may already have invalidated authentication and require UART0/SSH recovery.
- Console snapshots expose the existing deferred-pending flag. While deferral is observed, HTTPD wipes buffered/new input rather than replaying it after cancellation. The incoming frame's discard decision is latched before bounded payload reception, so cancellation during reception cannot reclassify that frame as a new command. A second frame arriving before the periodic poll does not turn deferred trailing input into a session-closing overflow. Normal invalid/oversized/fragmented frames and nondeferred buffer exhaustion still fail closed. Discard events contribute to existing input-backpressure counts.
**Intentional effects:** `web stop` closes both browser-admin and serial WebSockets and invalidates HTTPS sessions. A web writer therefore loses its broker lease; USB, UART0 and SSH are not stopped. Restart HTTPS through UART0/admin SSH using `web start`, then log in again. `reboot` restarts the entire device and loses RAM-only configuration. Normal terminal selection and admin-only `exit` retain their existing serial-isolation behavior.
## Restrictions still in force
These remain deliberately blocked for WEB, before canonical handler side effects:
- All `web` forms except exact parsed `web status`, `web stop` and `web certificate rotate --force`. This still blocks certificate info, start, help/counters, legacy credential display/rotation and full material reset.
- Wi-Fi/mDNS commands except exact status queries.
- User mutations; allowed inspection remains bare `user`, `user status`, `user list`, `user show <name>`.
- SSH stop/disconnect/reset and host-key mutation (host-key info remains allowed).
- Bootstrap/recovery remain UART0-only for all remotes. No one-time self-generated credential workflow was enabled.
The next increment remains **within 8D.7**: credential/account handling, then other owner-specific restrictions in bounded slices. The user explicitly authorized stacking the next bounded slice without target sign-off. Preserve explicit rejection until each path is implemented. Do not proceed to settings or close M2 based on either slice.
## First-slice executed verification
| Command | Result |
|---|---|
| `python3 tests/admin_console_boundary/run.py` | Shared console/SSH, dispatcher-origin, pending snapshot, action bounds, queue/drain/cancellation and existing prompt/currentness regressions pass |
| `python3 tests/admin_console_boundary/lifecycle.py` | New extracted canonical-handler checks pass: browser stop deferred, SSH/UART stop unchanged, remote reboot and scheduling-failure isolation |
| `python3 tests/admin_ssh_policy/run.py` | Actual IDF parser permits only the intended new WEB forms, including quotes; remaining restrictions/SSH policy pass |
| `python3 tests/web_admin_transport/run.py --tickets` | **23 transport / 12 ticket groups pass**, including stale/revoked action rejection, control-task API routing/error propagation, pending input, second frame before poll and cancellation during receive |
| `python3 tests/web_admin_transport/server_lifecycle.py` | **11 groups pass**, including detach timeout, failed stop/retry and optional failure isolation |
| `python3 tests/web_cookie_auth/run.py --admin` | Real cookie/store/ticket/adapter/admin endpoint integration passes; lifecycle calls doubled and not invoked by endpoint tests |
| `python3 tests/web_session_store/run.py --serial` | Store/serial binding/isolation/race regressions pass |
| `python3 tests/web_ui_session/run.py` | Renderer/CSP and **17 UI groups pass**; no UI changes |
| `git diff --check` | Pass |
| `pio run` | Final source build **12.44 s**, finite 180-second timeout; **95,580 B linked RAM / 1,647,025 B flash** |
Independent review identified the second-frame-before-poll cancellation bug, then cancellation-during-receive reclassification. Both were fixed with regressions; reviewer confirmed the final correction with no remaining findings in it. An initial console test still expected `web stop` denial; updated it to a still-restricted command. These are not target failures. An earlier pre-review-final build was 1,646,965 B flash; the table above supersedes it.
Tests use deterministic dependencies, not real concurrent RTOS/HTTPD/TLS/device execution. Actual command handlers, console state machine, transport, and server orchestration are exercised in focused harnesses, not one fully linked concurrent end-to-end firmware harness. No sanitizer pass or on-device stop/reboot is claimed.
## First-slice resources
Versus signed-off 8D.6 (95,580 / 1,646,489 B): **0 B RAM / +536 B flash**. Versus recorded 8D.0 (94,532 / 1,599,973 B): **+1,048 / +47,052 B**. Baselines were not rebuilt.
No new static payload/state, module heap/PSRAM allocation, task, stack-size, queue-depth, HTTP handler, TLS/socket, ticket or session capacity. The snapshot adds a boolean describing already-existing console state; callers use local snapshot scratch. WEB lifecycle validation copies one secret-free principal on the existing control stack. Existing control stack is 4 KiB, dispatcher 12 KiB; runtime stack high-water marks for these new call paths remain unmeasured. HTTPD remains 10 KiB, SSH owner 20 KiB, web payload 1,552 B PSRAM-only, six HTTPD sockets and two shared console slots.
Carry forward 8D.6 loaded lifetime minima **6,516 B internal / 1,580 B DMA**, noting overlapping capabilities and conservative regional sums. Numeric reserves remain open; no safe margin or new reserve violation is inferred. Do not reopen the prior sign-off solely for incomplete numeric instrumentation.
## Historical pending target checklist — both slices
1. User-controlled flash/reload; capture exact revision, 60-second settled `memory`, web/SSH status and available stack telemetry. Check browser login/serial/admin, USB UART1, user/admin SSH and UART0 at the established 230400-baud workload.
2. With browser serial + admin and USB/user/admin SSH active, issue **`web stop` from browser admin**. Expect its best-effort scheduling acknowledgement, then both browser routes close; UART0/USB/SSH remain usable. Confirm no browser writer remains. Restart with UART0/admin SSH `web start`, reauthenticate and reconnect. Repeat five times and compare full-mix/cleanup heap and largest blocks; do not expect boot equivalence when UART/clients remain active.
3. With any RAM-only settings loss understood, issue browser **`reboot`**. Expect best-effort acknowledgement and device restart, then usable UART0 recovery and browser login. Verify no replay of the command on reconnect.
4. Exercise queued/slow output, trailing input and rapid additional input during deferral. No cross-session output, no deferred input replay after cancellation, no unrelated admin-only-close effect on serial. Logout/revoke/disconnect before execution should prevent stale actions. Timing/failure injection not practical on target remains explicitly unexecuted rather than assumed passed.
5. Check remaining forbidden commands still reject without side effects, and SSH/UART0 stop/reboot/certificate behavior remains unchanged. For browser rotation, reject missing force, extra arguments and other certificate forms without identity changes; accept only exact parsed `web certificate rotate --force` (including equivalent quoting).
6. With browser serial/admin plus USB and user/admin SSH active, rotate the certificate. Expect only a best-effort scheduling acknowledgement, both browser routes closing, old session/ticket rejection and loss of a web writer lease. Verify the changed public fingerprint through trusted UART0/admin SSH `web certificate info`, update browser trust deliberately, log in again with unchanged account credentials and reconnect. Confirm USB UART1/SSH traffic and UART0 recovery remain available; do not log private keys, passwords or session/ticket material.
7. Repeat rotation/relogin under the established full mix and compare loaded/cleanup memory and largest blocks. Exercise revocation/disconnect/slot reuse before dispatcher execution, queued prompts/slow output, trailing input and no replay. Collect control/dispatcher/HTTPD stack high-water evidence where instrumentation permits; record unknown margins rather than substituting host sizes.
8. Where safely injectable, verify generation/persistence failure skips stop/start and retains old material; stop/detach failure after commit retains ownership, skips start and may leave auth disabled; successful stop followed by start failure retains the new persisted identity. Recover through UART0/admin SSH by inspecting state and completing stop/start as appropriate, with no duplicate HTTPD start or blind rotation retry. Unavailable target failure injection remains unexecuted.
Do not run these disruptive commands automatically. The user has explicitly authorized stacking the next bounded runtime slice; no hardware checklist item is thereby passed. Final M2 acceptance remains pending and requires lifecycle parity, the full mixed-client/soak/resource checkpoint and explicit target/browser acceptance before settings.
-97
View File
@@ -1,97 +0,0 @@
# Phase 8D.8 Implementation Record
## Target sign-off (2026-09-08)
The user supplied settled boot/full-client-mix evidence and reports thorough testing of Serial parameter display/settings and user/account settings, explicitly authorizing sign-off of implemented work. **8D.8 is accepted**, together with implemented 8D.9/8D.10. This supersedes target-pending/signoff statements below, not historical build evidence. Full telemetry/counters and scope limits are recorded in `docs/phase8d10_implementation.md` under Target acceptance. Exact flashed revision and individual checklist results were not separately supplied; do not invent them or reopen explicit acceptance solely for absent detailed records. Numeric memory reserves/stack margins and the earlier accepted admission followup remain open. No runtime change/build/device action in this documentation update; no later-phase authorization inferred.
## Historical Status and Scope
2026-09-07: separately user-authorized 8D.8 is **implemented / host-tested / build-verified**. Target/browser validation and new phase signoff remain pending. No 8D.9 work, mutation/persistence controls, placeholder domains, all-subsystem settings snapshot, CLI-over-HTTP, generated-asset change, upload, erase or commit. Worktree was clean at task entry; no unrelated edits were reverted.
**M2 remains explicitly signed off** by the user ("Jupp, sign M2 off"). Accepted 8D.7 scope does not need revalidation. Deferred browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain unchanged; bootstrap/recovery remain permanently UART0-only. The intermittent supported two serial + one admin admission issue remains accepted nonblocking, unresolved and undiagnosed. Numeric memory reserves, low historical internal/DMA minima and stack margins remain unapproved followups. Nothing here establishes full browser command parity or fixes that admission issue.
## Implemented Contract
- `src/web_ui.c`: Settings joins the existing admin-only Serial/Admin selector and opens the only implemented settings page, Serial, within the same document. It displays working UART1 service state, baud, data bits, parity, stop bits, flow control, DTR behavior and RTS threshold. It explicitly does not represent saved NVS state.
- Selection never creates/closes a serial or admin socket, joins/disconnects the broker, or requests/releases the writer lease. Both hidden terminals keep draining through their existing independent 5,000-line scrollbacks and 64 KiB pending-output bounds/drop notices. Only the selected terminal accepts keyboard input; neither does in Settings. Writer status and explicit writer controls remain available.
- Entry/Refresh first revalidates the existing username/role/session-stable CSRF identity, then requests `GET /api/settings/serial`. Settings validation must not supersede an in-flight serial-admission session check; newer admission may supersede an older Settings check, which returns to explicit retry. Identity change still requires a clean document before adoption. Absolute expiry is never extended.
- One active Settings refresh, no new periodic poll, no automatic retries, and the existing fixed-path/same-origin/no-store/redirect-denial fetch helper. Each fetch has a 15-second deadline; the two sequential session/snapshot requests can take up to approximately 30 seconds plus scheduling. Snapshot input is capped at 256 bytes, fatal UTF-8/JSON decoded, checked for exactly eight fields with bounded types/ranges/enums, and rendered only with `textContent`.
- Leaving Settings aborts and clears it. Logout/401/expiry/pagehide use existing two-route teardown and also clear/hide Settings. Late replies cannot repopulate it. Same-session bfcache restore revalidates before showing the empty page; Refresh is explicit and admin is not reopened automatically. Ordinary Settings failure does not close either terminal. Shared bounded error messages never display arbitrary response/error bodies; 429/503 retry hints are capped, and retries are manual.
- The external authored app script changed; the inline loader did **not**. Its two existing CSP hashes remain exact and unchanged, verified against the actual rendered loader. CSP was not relaxed. No vendored/generated assets were regenerated.
## Backend and Ownership
`serial_service_get_snapshot()` copies `serial_config_t` and running state under one **zero-wait** acquisition of the existing serial state mutex. NULL is invalid; a non-NULL output is cleared on failure; uninitialized returns invalid-state and contention returns timeout. No UART/GPIO/NVS/broker IO, additional allocation or new lock. This avoids the blocking config getter on the new HTTPD route and cannot stall behind a console stop/reconfiguration. It is a point-in-time working snapshot, not a promise that later console changes cannot occur.
`web_server.c` authorizes with the existing real cookie/session/current-principal policy and then requires admin before any serial read. The route accepts only bodyless GET without query parameters. Existing Host/origin binding and Fetch Metadata policy apply: same-origin GET can omit Origin; supplied cross/null Origin rejects. GET is nonmutating and requires no CSRF token. Duplicate headers, malformed/oversized credentials, transfer framing, unknown query fields and bodies reject through existing bounded policy. Unread rejected bodies cause connection closure rather than unbounded draining. Auth/principal temporaries and consumed header scratch are wiped.
Success is bounded JSON (256-byte stack buffer including terminator), containing only fixed firmware enum names, boolean and integers. No secrets, configuration version/storage blobs, counters or unrelated subsystem data are serialized. Missing/expired/revoked login gives 401; normal role gives 403; invalid request gives 400; busy/uninitialized serial gives 503 with `Retry-After: 1`. Common error routes give fixed 404/405 for unknown routes/methods. Responses are no-store/nosniff/no-referrer. Header/send failures stop without a second response; existing aggregate request/auth/response counters are reused.
Registration is optional and independent of admin-console attachment: failure leaves base auth/serial/admin usable, with Settings returning the existing safe 404 and manual retry message. URI capacity increases from 16 to 17; sockets remain six with LRU disabled. No admission eviction or capacity change for sessions, challenges, tickets, console slots or WebSockets.
### IDF Allocation Boundary
Review of installed IDF 5.5.0 `httpd_uri.c:147-163` found that public URI registration publishes an allocated descriptor before duplicating its name; name-allocation failure frees it without clearing `hd_calls`. Ignoring that failure for the new optional route could leave a dangling table entry. `web_httpd_register_optional_get()` in the existing private adapter stages both ordinary heap allocations before publishing the fully initialized entry. It is restricted to serialized startup, exact-match GET, no WebSocket/subprotocol and a 127-byte URI limit; duplicate/full/invalid shape rejects without publication. HTTPD retains normal ownership and frees both allocations. No SDK patch or runtime registry framework was added.
The new route alone uses this helper. Existing public registration callers were not refactored; broader inherited SDK allocation-failure handling remains a followup, not a diagnosed cause of the accepted admission issue. The version-pinned adapter now also requires registration/table/free-ownership re-audit on SDK updates. Host tests inject failure at both actual adapter allocations and compile the installed unregister implementation to exercise successful cleanup. They do not prove multicore registry timing or real low-heap behavior.
## Resources
Final release `pio run`, tool timeout **120 seconds**, passed in **24.31 seconds**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0 / N16R8:
| Resource | Final / Delta |
|---|---|
| Linked RAM | **95,580 B**, **0 B** vs recorded 8D.7; **+1,048 B** vs 8D.0 |
| Flash | **1,654,529 B**, **+5,952 B** vs recorded 8D.7 (1,648,577 B); **+54,556 B** vs 8D.0 (1,599,973 B) |
| HTTPD handlers | 17, +1; table +4 B on target |
| New route allocations | Target descriptor 24 B + URI/name terminator 21 B = 45 B; with table growth **49 B requested heap**, excluding allocator rounding/metadata |
| Snapshot / response scratch | Target snapshot 36 B; response array 256 B; caller principal 40 B |
| Compiled local frames | Settings handler 416 B, snapshot getter 32 B, registration adapter 48 B, **excluding callees** |
| Authored HTML/app arrays | 10,432 / 32,061 B including terminators in target ELF; flash-resident, not module heap |
| Tasks/stacks/queues | No new task, stack-size change, queue, depth, mutex or timer |
| Sockets/capacity | Six HTTPS sockets, two serial + one admin WebSocket; all session/ticket/shared-console capacities unchanged |
| Browser resources | Eight fixed value nodes, one active refresh; snapshot body <=256 B, session body <=512 B; existing terminal bounds unchanged |
Sizes/entry frames above were inspected with the target GDB against the final ELF. The URI table allocation was verified in installed `httpd_main.c` and descriptor/free ownership in `httpd_uri.c`. Ordinary `malloc`/`calloc` follow existing IDF allocator policy, with no new PSRAM-only requirement or fallback policy; conservatively budget all 49 requested bytes as possible internal heap. No module persistent heap or PSRAM payload was added beyond HTTPD's route ownership. TLS/request allocations, allocator overhead, actual internal versus PSRAM placement, browser engine storage and full call-chain stack usage remain unmeasured. Static RAM and local frames are **not** runtime reserve evidence.
Earlier pre-review build passed in 25.14 seconds at 95,580 B RAM / 1,654,021 B flash. The final build above supersedes it and includes the reconnect and allocation-publication fixes.
## Verification
All commands below passed in this task; compiler/test subprocesses and firmware builds had finite tool timeouts. No sanitizer run or hardware operation was performed.
| Command | Result / Scope |
|---|---|
| `python3 tests/web_cookie_auth/run.py --settings` | Existing store/auth suite + **5 Settings groups**: real auth/store/parser/adapter, exact extracted production handler/helper/snapshot/enum bodies, allocation failure and installed unregister; fake UART mutex/database/HTTP IO |
| `python3 tests/web_ui_session/run.py` | **21 Node groups**, production-C renderer/headers/failures, exact loader CSP; includes 10 Settings cycles with retained sockets/IDs, hidden output/input, malformed/oversized/errors/timeout/manual retry, cancellation/restore/identity and concurrent serial reconnect |
| `python3 tests/web_admin_transport/server_lifecycle.py` | **12 groups**, required/optional registration failure and stop/restart ownership; Settings allocation failure does not disable either transport |
| `python3 tests/web_cookie_auth/run.py --admin` | Store/auth plus real admin ticket-to-upgrade integration, isolation, currentness and expiry |
| `python3 tests/web_admin_transport/run.py --tickets` | **25 transport / 12 ticket groups** |
| `python3 tests/web_session_store/run.py --serial` | Store and serial/session integration |
| `python3 tests/web_login_ui/run.py` | Production renderer/CSP plus **8 Node groups** |
| `python3 tests/web_auth_parse/run.py` | **268 cases, 0 failures** |
| `python3 tests/admin_console_boundary/run.py` | Shared console/currentness, deferred certificate handoff and production SSH adapter regressions |
| `python3 tests/admin_console_boundary/lifecycle.py` | Canonical lifecycle and unchanged SSH/UART0 paths |
| `python3 tests/admin_console_boundary/accounts.py` | Existing account operation-admission/persistence/isolation regressions |
| `python3 tests/admin_ssh_policy/run.py` | Existing parsed restrictions and role/transport policy |
| `pio run` | Final build above, PASS |
| `git diff --check` | PASS |
The initial Settings test incorrectly reused a session after testing authoritative stale-principal invalidation; the real store correctly retired it. Fixtures now mint independent sessions for stale, database-failure and success cases. This was a test error, not an authentication-policy change.
`todowrite` and a task/subagent tool were unavailable in this session. Progress was tracked in commentary and this record. A separate implementer review pass found/fixed the reconnect-supersession and IDF publication issues, followed by affected suite reruns and the final build. **Independent agent review was not available and is not claimed.** No remaining actionable finding was identified in the implementer's final review; that is not an independent review result.
### Independent Review Completion
Parent obtained a separate agent review after implementation. No actionable findings were identified in authorization/secrecy/bounds, nonblocking snapshot locking, navigation/session/expiry races, or the optional registration adapter's ownership against installed IDF 5.5.0. Reviewer reran Settings/auth (5 Settings groups), UI (21 groups plus renderer/CSP), and server lifecycle (12 groups), all passing. This supersedes the implementer-session independent-review limitation above. No production edits or additional build were needed for review. Real browser/HTTPD scheduling, hardware and runtime reserve limits remain unverified.
## Pending Target Checklist
- Record flashed revision/browser(s)/direct-IP or mDNS origin. On desktop and mobile, verify layout, scrolling, keyboard/touch navigation, focus/pressed states, visibility of writer controls and CSP console cleanliness.
- Compare all eight Serial fields against UART0 `serial status` at a stable configuration, while stopped and running; make working-only changes via existing UART0/SSH admin controls, then Refresh. Confirm Settings never starts/stops/applies/saves/loads/resets serial or changes persisted values.
- With live serial writer and open admin, switch Serial/Admin/Settings at least 10 times and Refresh. Confirm same broker client/writer IDs, continuous serial/admin output, no input while Settings is selected, retained admin prompt/history and bounded overflow notices. Confirm explicit Disconnect and Close admin still affect only their intended route.
- Exercise normal-user direct endpoint denial; unknown query/body/method rejection; expired/logout/revoked session and account switch; delayed response/cancel, bfcache restore and explicit Refresh; unavailable/busy serial and optional route allocation failure where injectable. Confirm no old settings/terminal identity is exposed to a replacement login.
- Run UART0 recovery, native USB UART1, user/admin SSH and both browser routes concurrently, at the supported workload used for M2 (user reported up to 230400 baud). Include Settings reads while console reconfiguration is active. Record any existing admission failure by stage without asserting it is fixed or caused by this phase.
- Record settled boot, connected/full-mix and post-cycle/cleanup `memory` free/minimum/largest internal/DMA/PSRAM values, HTTPD and relevant task stack margins where instrumented, duration/byte/drop counts and any allocation failure. Runtime floors/budgets remain pending user approval; do not infer safety from linked RAM.
Stop at 8D.8 for target validation/user decision. **8D.9 is not started and requires a separate request** after that decision. Prior M2 signoff remains accepted regardless of these new-phase pending checks.
-93
View File
@@ -1,93 +0,0 @@
# Phase 8D.9 — Serial edits and persistence
## Target sign-off (2026-09-08)
The user supplied settled boot/full-client-mix telemetry and reports thorough testing of Serial parameter display/settings and user/account settings, with explicit sign-off of implemented work. **8D.9, including its automatic-completion UX refinement, is accepted**, together with implemented 8D.8/8D.10. This supersedes target-pending/signoff statements below, not historical evidence. `docs/phase8d10_implementation.md` records all samples, counters and limits. Exact flashed revision, durations and individual persistence/failure-injection checklist results were not separately supplied; broad user acceptance is not a claim that every listed case was individually observed. Numeric reserves/stack margins remain followups, not reopening sign-off. No new runtime/build/device action; wait for separately requested 8D.11 after accepted 8D.10, not inferred M3 completion.
## Historical state and scope (2026-09-07)
Implemented, reviewed, host-tested and firmware-build verified; **target/browser validation and phase signoff remain pending**. Resumed the previous agent's uncommitted implementation at the user's request. Continuation was explicitly authorized despite pending 8D.8 target validation; it does not sign off 8D.8 or 8D.9. M2 acceptance stands. No later domains, quick popover, generated assets, upload, erase, or commit actions.
## UX refinement (2026-09-08)
User reported Apply succeeds but manual Check Result hides the editor without refreshing the displayed configuration. User approved automatic completion/refresh and removal of routine disruption popups. Implemented in `src/web_ui.c`, with no backend/protocol change:
- Actions submit once, show applying/pending and keep settings visible with conflicting controls disabled and an explicit stale-snapshot message.
- After acknowledged submission, result checks wait one second initially and between pending replies, capped at **10 GET attempts and a separate 15-second overall monotonic deadline** including session revalidation. The deadline aborts an in-flight check; late callbacks are fenced. Browser timer scheduling is not a hard real-time guarantee.
- Known terminal results, including failures, automatically refresh working values while retaining outcome/uncertainty messages. Refresh failure leaves existing values visible and explicitly stale; Refresh remains available. The completion refresh has its own existing per-request bounds, outside the auto-check deadline.
- Errors/exhaustion/lost acknowledgements stop automatic checking and retain Check Result for manual recovery. Navigation/pagehide/logout/identity changes cancel checks without auto-resumption; they do not cancel backend work. No automatic POST retry.
- Apply/Save/Load/Defaults/Start/Stop no longer show confirmation dialogs. Reset alone confirms overwriting saved NVS configuration. Inline RAM/NVS/discard semantics remain.
- Review found repeated selection of the current Settings tab cancelled requests/polling; selecting the current view is now a no-op, with a regression covering submission, inter-check delay, in-flight checks and completion refresh.
Final parent validation: `python3 tests/web_ui_session/run.py` **35 behavior groups plus renderer/header/failure/exact CSP checks**, `pio run` **PASS 10.91 s**, `git --no-pager diff --check` PASS. Build **95,708 B RAM / 1,668,837 B flash**, **0 / +2,112 B** versus the original 8D.9 build below (**+128 / +14,308 B** versus 8D.8). Static build accounting is not runtime/stack telemetry. Tests model DOM/fetch/timers/WebSockets; new flow not target-validated. No upload, erase, asset regeneration or commit. Original validation below remains historical evidence, not a claim that backend suites were rerun for this UI-only refinement.
## Contract
- Existing admin-only Settings/Serial now has typed baud, data bits, parity, stop bits, flow control, DTR and RTS-threshold drafts, plus explicit Apply, Start, Stop, Save, Load, Defaults and Reset controls. Server authorization, not UI visibility, enforces admin access.
- `POST /api/settings/serial-operation` requires current cookie/principal, admin role, canonical Origin and CSRF. JSON is at most 256 bytes and four receive calls, with exact action-only or complete Apply schema; unknown/duplicate fields, escapes, nesting, invalid framing and out-of-range values reject. Failed unread bodies close rather than contaminate the next request.
- HTTPD admits only an operation ID to the existing four-entry administration queue with zero wait. `web_serial_settings` owns one static session-bound pending/result slot; the existing 12 KiB dispatcher executes typed serial APIs, never CLI text or HTTPD-owned socket operations. Queue/slot exhaustion returns 503 with Retry-After; no new task or generic job framework.
- Execution checks originating cookie/principal/admin currentness and the 30-second queue-admission deadline before side effects. This deadline is checked on dequeue: a blocked dispatcher retains the slot, and the deadline is neither an execution timeout nor a slot-release timer. Work already admitted to serial/NVS APIs may finish after logout/revocation. IDs do not wrap/reuse; stale queued IDs cannot execute replacement work.
- Bodyless `GET /api/settings/serial-operation` exposes only the current login's retained `{id, action, state}` (96-byte response buffer). States are idle, pending, ok, failed, cancelled, loaded_defaults and rollback_failed. A subsequent admitted operation, including another session's, can replace a completed result. This is neither durable history nor an idempotent retry API.
- Result checking is single-flight and automatically bounded after acknowledgement, with manual recovery as detailed above; mutations are never automatically retried. Lost acknowledgements and replaced results retain uncertainty warnings across further reads, Refresh and navigation. Cancellation/navigation is not a promise that admitted work stopped. Inspect current working settings and CLI storage before deciding to retry.
- Settings navigation preserves both terminal sockets, hidden output, broker client and writer lease. The operation itself may stop/reconfigure UART1 and discard serial-service RX/TX/task-pending bytes; already-fanned broker output and writer ownership remain unchanged. Open USB can restart a stopped service.
### Working versus persisted state
| Action | Canonical behavior |
|---|---|
| Apply | Validate and replace all working fields; running service stop/restart and best-effort rollback use existing service API. Overwrites intervening CLI edits. No NVS write. |
| Start / Stop | Existing service lifecycle APIs; no persistence or broker lease change. |
| Save | Persist device working configuration at execution, **not the browser draft**. |
| Load | Load and apply stored config; absent/incompatible storage applies defaults and reports `loaded_defaults`, without writing NVS. |
| Defaults | Apply defaults to RAM only. |
| Reset | Capture previous working config, apply defaults, persist defaults through canonical reset API; on persistence failure attempt runtime rollback, reporting rollback failure distinctly. |
Refresh replaces the browser draft. Snapshots do not expose a saved-config copy or claim a dirty/persisted comparison. There is no compare-and-swap protection against intervening CLI edits.
## Integration and continuation fixes
- `src/web_serial_settings.{c,h}`: parser, bounded operation slot, typed execution, result protocol.
- `src/admin_ssh_console.{c,h}`: typed ID in existing queue union and dispatcher; no console slot consumed.
- `src/web_cookie_auth.{c,h}`: bounded-JSON mutation authorization while preserving bodyless policies for existing endpoints.
- `src/web_httpd_adapter.{c,h}`: existing failure-safe optional registration generalized to exact GET/POST. `src/web_server.c` registers result GET first and mutation POST second; partial failure cannot leave a mutation-only endpoint. Optional failure preserves both transports. SDK-private boundary remains pinned to IDF 5.5.0; inherited non-Settings registration audit remains open.
- `src/web_ui.c`: explicit typed controls, Reset-only confirmation, bounded automatic completion/refresh and manual result recovery and session/navigation fencing. Review fixed uncertainty warnings disappearing after later result checks/Refresh/navigation.
- `/api/status` now uses the zero-wait serial snapshot rather than blocking config acquisition. Review fixed a separate live running read mixing moments with the snapshot. When unavailable, `running` is JSON `null` and `config_available` is false; consumers must not interpret that as an authoritative stopped state.
- Backend review found no further confirmed runtime defects; added parser/security/queue/currentness/race regressions and documented deadline/result limitations. Existing parent/user edits in `docs/phase8d8_implementation.md` were preserved.
## Validation performed
Final parent sequential run, all passed:
```sh
python3 tests/web_cookie_auth/run.py --serial-settings
python3 tests/web_cookie_auth/run.py --settings
python3 tests/web_ui_session/run.py
python3 tests/admin_console_boundary/run.py
python3 tests/web_admin_transport/server_lifecycle.py
pio run
git --no-pager diff --check
```
- Serial: 10 groups plus shared auth/store regressions, including 288 valid framing/range combinations, 256-byte/four-read boundaries, strict rejection, no HTTPD serial/NVS execution, session isolation, stale IDs, simulated concurrent reads/submissions, canonical CLI/API ordering, failures/reset rollback, account revocation and cookie expiry before the queue deadline.
- Settings/status: 6 groups plus shared regressions. Status serial projection compiles production acquisition/format/arguments, verifies consistent snapshot across changed live state and null on unavailability; it is not the full multi-subsystem handler.
- UI: 27 behavior groups plus production C renderer/header/failure and exact loader CSP hash checks. DOM/fetch/WebSockets are modeled, not on-wire integration.
- Console boundary: FIFO/zero-wait typed admission, full queue preserves all four UART requests; existing console/certificate/SSH-adapter regressions.
- Server: 13 lifecycle groups, including optional GET/POST failure and recovery, six sockets/no LRU.
Additional passing runs during continuation: parent transport `run.py --tickets` (25 transport/12 ticket groups), session store `run.py --serial`; backend review agent cookie `--admin`, console `accounts.py` and `lifecycle.py`. These use deterministic RTOS/UART/NVS/transport doubles, not real hardware scheduling, flash failures or rollback. No sanitizer run or target pass claimed.
### Resource accounting
Final `pio run`: **23.73 s; 95,708 B RAM / 1,666,725 B flash**. Delta against recorded 8D.8: **+128 B RAM / +12,196 B flash**; against 8D.0: **+1,176 / +66,752 B**. Build totals are static/linker accounting, not runtime free heap or stack margins.
Two additional optional handlers (GET and POST at one path) raise the configured URI budget from 17 to **19**. Six HTTPS sockets/no LRU, two serial sockets/one admin socket, existing queue depth, tasks and stack allocations are unchanged. Additional route descriptors/names/table pointers consume runtime heap beyond static RAM; exact allocator overhead and handler/dispatcher high-water marks have not been measured. The bounded request/result buffers are 256/96 bytes. No generated asset changes; loader hash remains unchanged and verified. Numeric memory reserves/stack margins remain unapproved.
## Pending target checklist / next step
1. Flash only by explicit user action/authorization; compare browser Refresh with UART0 serial config/status and confirm ordinary users cannot access settings/mutations.
2. Compare every browser action with CLI semantics, valid framing boundaries and invalid requests. Verify Apply/Defaults versus Save/Load/Reset across reboot; confirm explicit Refresh draft replacement and concurrent CLI overwrite warning.
3. Exercise start/stop/reconfigure under USB + SSH + both browser serial observers + browser/SSH admin. Confirm expected serial-service discards, unchanged broker writer/client identity, isolated output and USB restart behavior; do not mistake expected discards for a regression.
4. Hold dispatcher in a prompt, submit once, observe bounded busy behavior and dequeue deadline cancellation. Test logout/account revocation while queued, expiry, navigation, lost acknowledgement, replacement by another tab/login, and manual uncertain-result recovery without blind resubmission.
5. Where safely injectable, verify NVS/load/apply/rollback failures and physical port fault behavior. Host doubles are not evidence of hardware rollback success.
6. Capture settled boot/full-mix/post-cleanup internal/DMA/PSRAM free/min/largest and dispatcher/HTTPD stack high-water data; test slow/fragmented body requests alongside serial traffic. Existing intermittent supported-mix admission issue remains accepted nonblocking and unresolved.
7. Obtain explicit 8D.8/8D.9 target acceptance as appropriate. Stop before separately requested 8D.10. M2 is not reopened, deferred browser owner/credential restrictions remain, and no memory reserve approval is inferred.
-296
View File
@@ -1,296 +0,0 @@
# Phase 8D.0 — Baseline and M1 browser contract
Status: **8D.0 and 8D.1 validated by user sign-off on 2026-09-05.** Documentation/source audit, builds and target runtime samples are recorded. Numeric reserve floors and future incremental budgets remain open engineering gates, not blockers to these user-approved closures. The M1 browser contract below was established during baseline planning; the subsequent [8D.3 live cutover record](phase8d3_implementation.md) now documents implemented/host-tested/build-verified authentication and explicit 8D.3/M1 user sign-off after post-soak evidence on 2026-09-06. Numeric reserve gates remain open. Baseline measurements and source-behavior descriptions here remain historical, not measurements of the live cutover. See [execution plan](phase8d_plan.md) and [acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration).
## Validation sign-off
The subsequent [8D.2 implementation record](phase8d2_implementation.md) contains its separate build/resource accounting, target samples and user sign-off. The baseline and 8D.1 measurements below remain historical evidence, not 8D.2 target validation.
- The user explicitly marked **8D.0 validated** and identified the tested firmware with the latest checked-in project state, resolved at sign-off to Git revision **`d8999cd4a96e477fabd392ced02d810c3cd22d0f`**. This is user-confirmed source provenance, not an independently read-back device binary hash. The earlier reproducible build revision and SHA-256 table remain historical build evidence, not newly generated hashes for this revision.
- The user attributes the SSH I/O errors to testing at **out-of-spec 460400 baud**. Preserve that exact reported rate separately from the transcripts' **460800-baud UART configuration**; the differing rate may describe the test setup, and no firmware baud-support change or independently reproduced diagnosis is implied. The session-revocation counter remains recorded without a separately supplied causal explanation.
- This sign-off closes 8D.0 using the supplied evidence. Per-sample missing-provenance and unverified-check notes below describe the evidence available when collected; source-revision uncertainty is superseded by this sign-off. Unrecorded detailed checks remain regression coverage, not claims that the agent executed them or outstanding blockers to 8D.0 closure. Numeric memory/stack reserve approval and post-change 8D.1 validation are not implied.
## Reproducible build baseline
- Recorded 2026-09-05 on `devel`, revision `af89dd1bd96cdd97d8d57eee7a29f68e3874506b` (`Define staged Phase 8D delivery plan`). Working tree was clean before measurement. No experimental-branch inspection/import, firmware edits, asset regeneration, upload, erase, or device reconfiguration.
- `pio run` passed in 36.08 seconds (120-second timeout). Release environment `esp32-s3-devkitc-1-n16r8`, `platformio/espressif32@6.12.0`, ESP-IDF 5.5.0, Xtensa toolchain 14.2.0+20241119. Existing framework Kconfig invalid-bool-default notes appeared; they did not fail the build.
- N16R8: 16 MB QIO flash, 8 MB octal PSRAM at 80 MHz, custom partitions. Application flash budget is 4,194,304 bytes, not the full chip. Wi-Fi/lwIP allocations prefer external RAM; the configured mbedTLS allocator is PSRAM-only, without internal fallback. Software crypto configuration is retained.
- `pio device list` completed with no ports listed. No target/browser connection was available to this task. Historical target reports are not fresh evidence for this revision.
| Measurement | This build | Historical mDNS reference | Difference |
|---|---:|---:|---:|
| Linked static RAM | 94,532 B | 94,532 B | 0 B |
| Program flash reported by PlatformIO | 1,599,973 B | 1,599,765 B | +208 B |
The flash difference predates this documentation-only change; its cause was not diagnosed. Linked RAM is not runtime heap headroom. No runtime delta can be inferred from these figures.
SHA-256 identification (configuration and generated binary, not secrets):
| File | SHA-256 |
|---|---|
| `platformio.ini` | `65ca5103c7aaf36b685a05371a856294ba6f7f5f53209c68d61a828d3beb78e5` |
| `sdkconfig.defaults` | `af8fb8a9866888a12219a9d4ff5148d9f45f17c40067dce90fbc55c0b75986e5` |
| `sdkconfig.esp32-s3-devkitc-1-n16r8` | `c9c8c08b18027e959ba8131686f0e42573e507574079be3cf8fb72a9e1955905` |
| `partitions.csv` | `9107a2aab52f02633aea72683ffede979d9db2050e95ba4788cd0f8580c7f0ff` |
| `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin` | `c5f10b2137d2cbffad59cda025274dd82aad1a42cdea2144d31424fd918b4e6d` |
## Source-verified current behavior and capacities
Authoritative paths: `src/web_server.c`, `src/web_serial_transport.{h,c}`, `src/web_ui.c`, `src/user_database.h`, `src/system_console.c`, `src/session_broker.{h,c}`, `src/ssh_transport.{h,c}`, `src/admin_ssh_console.c`. Configuration-dependent defaults below were also checked against installed IDF 5.5 HTTPD/HTTPS headers and implementation and targeted generated sdkconfig entries.
- Both roles currently use Basic authentication for the same serial/status UI; there is no login page, cookie session, logout, or browser admin route. All five app assets require authentication. Basic header buffers are wiped after verification. The four-entry keyed-digest cache has a 300-second sliding TTL and rechecks principal currentness; cache expiry is not browser logout.
- Ticket issuance accepts only an empty POST body. Serial upgrade consumes a one-use, principal-bound ticket; supplied Origin must match `https://<Host>`, but missing Origin is currently accepted. Four tickets, 30-second lifetime, 24 random bytes encoded to 32 characters, digest-only storage. Full ticket storage currently evicts the earliest-expiring entry.
- Serial admission opportunistically requests writer ownership. Explicit browser Disconnect closes the socket and pauses reconnect; Connect resumes it. Binary data and existing `request-writer`/`release-writer` messages remain unchanged in M1. Status polling runs every five seconds. Current fetch failures do not distinguish expired login from transport failure; cutover must change this.
- Principals copy account ID, authentication generation, role, method and username. Database limits are eight accounts, username capacity 16 bytes, password capacity 64 bytes. Mutation/recreation invalidates principal currentness; transport notification supplements, not replaces, that authority.
### Allocation and execution inventory
These are **source array capacities/configured stacks**, not measured allocator costs or complete `sizeof` totals. Do not add stack-local scratch to the stack allocation again.
| Resource | Existing bounded allocation or limit |
|---|---|
| HTTPS owner | One dynamic internal 10,240 B stack; serialized request handlers; dynamically allocated server/socket/route/TLS state |
| HTTP request/response scratch | Status JSON 3,072 B on stack; Basic header 115 B, decoded credentials 81 B, digest 32 B on stack; startup certificate/key scratch 1,024 + 256 B |
| HTTP parser/response headers | Request headers 1,024 B, URI 512 B, eight additional response-header slots; implementation must count cookie + security headers before cutover |
| TLS records | Configured input/output content limits 16,384/4,096 B per TLS connection; external allocation, dynamic resizing disabled; contexts, certificates, TCP and allocator overhead additional; HTTPS TLS resumption tickets disabled |
| Web serial | Two static slots, each 1,024 B RX + 512 B TX (3,072 B payload total), principals and metadata; four ticket records; one static 6,144 B stack + TCB; at most one pending HTTPD TX work item per slot |
| Broker | Eight preallocated clients, 4,096 B usable output/client (32,776 B backing storage including sentinel bytes); 16 events/client, additional `8 × 16 × sizeof(session_broker_event_t)` storage; payloads PSRAM-preferred with internal fallback, controls internal; one dynamic 4,096 B stack |
| SSH | Two total slots shared by serial/admin/handshakes, 512 B RX + 512 B TX each (2,048 B payload total), plus snapshots/metadata; one dynamic 20,480 B owner stack pinned to core 1; wolfSSH allocations PSRAM-preferred with internal fallback |
| Shared administration | Two static remote states with 5,899 B explicit byte arrays each (including 4,096 B output and four history entries); additional 1,024 B SSH and 1,024 B UART completion formatters; command queue four, deferred-control queue two; 256-character commands, ten parsed arguments |
| Admin tasks | Dynamic dispatcher 12,288 B, deferred control 4,096 B, UART frontend 6,144 B; no task per remote session |
| Serial service | 16 KiB RX + 8 KiB TX usable stream payloads PSRAM-preferred with internal fallback; UART driver/control/task storage additional |
The seven listed HTTPD/web/broker/SSH/admin task stacks total **63,488 B**; this is not a whole-firmware task inventory. Other existing tasks, including Wi-Fi, USB, serial, UI, mDNS and system tasks, contribute to the measured baseline. No new task is allowed for M1. Internal fallback is a worst-case reserve cost, not free PSRAM capacity. Exact padded structure sizes, per-TLS/SSH handshake peaks and fragmentation remain unmeasured.
### Supported concurrency and socket accounting
Configured capacity is not evidence that every combination has passed this revision's target tests.
| Resource | Baseline ceiling / intended workload |
|---|---|
| Broker clients | Eight, exactly one writer; normal physical-transport maximum is USB + two web serial + two user SSH = five |
| Web serial | Two simultaneously connected serial WebSockets, independently of four outstanding tickets |
| SSH | Two connections total; baseline full mixed workload uses one user and one admin |
| HTTPD routes | Nine registered / nine slots: `/`, `/api/status`, `/api/ws-ticket`, `/ws/serial`, five `/assets/` routes; no spare route slot |
| HTTPS connections | Six accepted clients total, including WebSockets; two serial sockets leave four HTTP/TLS slots, not four parallel handlers |
| HTTPD infrastructure | Three more descriptors: listen, UDP control receive, UDP control send; full HTTPS therefore uses nine |
| SSH descriptors | Listener + two sessions = three; combined HTTPS/SSH subtotal twelve of sixteen lwIP sockets |
| Remaining socket allowance | Four before other services (including mDNS), network diagnostics and transient accept/reject costs; not four guaranteed spare connections |
HTTPS has LRU purge **enabled**, one-second send/receive waits and five-second handshake timeout; SSH has a two-connection listen backlog and 15-second handshake deadline. Before M2, admission must protect retained serial sockets from LRU eviction; do not claim current settings already guarantee that protection. M2 must budget two browser sockets (serial + admin) per dual-mode browser: two such browsers would occupy four of the six HTTPS slots, leaving only two for ordinary HTTPS. This is arithmetic, **not approval of a future admin capacity**. Do not raise lwIP/HTTPD limits or reduce existing serial/SSH capacities to conceal pressure.
## Minimal M1 browser contract (planned)
Policy constants below are bounded functional choices, **not approved memory reserve floors**. No settings API, admin shell, generic command endpoint, new permanent task, Basic compatibility path, stored browser passwords/localStorage credentials/remember-me tokens, or generated-asset changes are included.
### Routes and small wire schema
All API JSON is UTF-8, length-bounded and safely encoded. Login accepts only `application/json`; no form/query credentials, duplicate/unknown fields, embedded NULs, oversized bodies, or unbounded parser allocations. Maximum login body: **512 bytes**, decoded username/password limits **16/64 bytes**, using existing database validation. Reject excessive bodies before buffering. New authentication JSON responses have a **512-byte serialized ceiling**; the existing status schema/buffer is unchanged. `X-CSRF-Token` is the only CSRF request header.
| Route | Access and contract |
|---|---|
| `GET /login` | Public minimal standalone login document; inline bounded CSS/script with matching CSP hash, no dependency on protected app assets. No automatic login or credential verification. |
| `GET /api/login-challenge` | Public same-origin pre-login bootstrap, empty body; returns `{ "csrf": string, "expires_in": integer }` (remaining whole seconds, at most 120) and pre-login cookie described below. |
| `POST /api/login` | Strict Origin + pre-login cookie + CSRF header; body `{ "username": string, "password": string }`; success `200 { "authenticated": true }` and new session cookie. Browser navigates to `/`. |
| `GET /api/session` | Session required; returns `{ "username": string, "role": "user" or "admin", "csrf": string, "expires_in": integer }`, remaining whole seconds. No raw session token or account/verifier internals. |
| `POST /api/logout` | Session + strict Origin + CSRF; empty body. Invalidate current session before `204`, expire cookie, browser navigates to `/login`. Never a state-changing GET. |
| `GET /`, five existing `/assets/` routes | Session protected. Unauthenticated document navigation to `/` receives `303 /login`; assets/API receive `401`, never login HTML masquerading as JS/JSON. |
| `GET /api/status` | Session required; existing status for either role. |
| `POST /api/ws-ticket` | Session + strict Origin + CSRF; empty body; preserve existing ticket response fields/protocol, bind ticket to originating session as well as principal. |
| `GET /ws/serial` upgrade | Session cookie + strict Origin + one-use matching-session ticket; no Basic fallback. Currentness and expiry checked before attach. |
Five new handlers mean **14 route slots total** at cutover if using these exact routes. This is a separately accounted bounded route-table increase, not a socket increase. Preflight/CORS access is unsupported. Other methods must not bypass policy. API failures use a bounded `{ "error": code }` without echoing submitted input; existing non-auth errors may remain plain text and the browser must tolerate both.
### Sessions, CSRF bootstrap and admission
- **Four authenticated sessions globally**, independent of two serial sockets and four outstanding serial tickets. New login issues 32 random bytes as 64 lower-case hex characters; store only a SHA-256 token digest with copied principal, origin binding, CSRF state, monotonic deadline and generation-safe session identity. Use `secure_random`; fail closed on RNG/init failure. Keep synchronization explicit for HTTPD lookups versus console invalidation; no cross-subsystem callbacks while holding session storage locks.
- Cookie: `__Host-sak-session=<token>; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`, no Domain. **Absolute lifetime one hour; idle expiry disabled in M1.** Polling, traffic and refresh never extend it. No sliding renewal or refresh token; reauthentication after expiry issues a new identity. This deliberately simple policy will interrupt a long-running serial session at expiry and must be visible to the user. Reboot/web-server stop clears sessions and tickets. Browser cookie lifetime is not server authority.
- CSRF state: independent 32 random bytes encoded as 64 hex characters, retained only in bounded session state and browser memory. Return via `/api/session` on page load; never localStorage, URL or logs. It does not substitute for the HttpOnly session cookie. Refresh does not rotate it or invalidate another tab.
- **Four independent pre-login challenges**, 120-second absolute lifetime. Bootstrap issues `__Host-sak-prelogin` with the same cookie attributes and `Max-Age=120`, backed by token digest, origin binding, separate random CSRF value and deadline. `/login` itself allocates nothing. Bootstrap reuses an unexpired matching challenge for that browser without reissuing the cookie or extending its lifetime, returning remaining seconds; otherwise reclaim expired slots and reject at capacity. Require a same-origin custom header `X-Login-Bootstrap: 1` on the bootstrap fetch; reject cross-site Fetch Metadata when present, and validate any supplied Origin. No CORS headers: another origin cannot read a challenge or make that custom-header request. This handles same-origin GETs where browsers omit Origin without weakening POST checks.
- Login consumes a valid challenge **on every credential attempt**, including wrong passwords; clear its cookie, wipe request/password scratch on all exits, and fetch a new challenge for a user-initiated retry. Concurrent tabs share the cookie: a stale form gets a recoverable challenge-expired error, not a login loop. Successful login always generates a fresh authenticated token (no fixation). Already authenticated browsers receive `409 already_authenticated` instead of silently replacing an account/session; account switching requires logout.
- Reclaim expired/stale records first; full live session/challenge/ticket tables return `503 capacity` with `Retry-After: 5`, with **no live-session/ticket eviction**. Thus ticket overflow intentionally changes from baseline earliest-expiry eviction to explicit rejection at cutover. A session never reserves a serial socket; full serial capacity can reject Connect without discarding login or disturbing an existing writer.
- Login throttle: one global bounded monotonic bucket allowing **five credential-verification attempts per 60-second window**, successes included; further attempts get `429 throttled` with seconds until window reset in `Retry-After`. Invalid CSRF/Origin never reaches password verification. No per-username/IP table, sleep in HTTPD, secret retention or persistent lockout. Tradeoff: a hostile client can exhaust shared login capacity; UART0/USB and established sessions remain usable. Do not claim comprehensive DoS resistance; measure PBKDF2 blocking/TLS pressure on target.
### Origin, expiry and revocation rules
- Every mutation (login, logout, ticket issuance) and WS upgrade requires a single non-null HTTPS Origin equal to the request's validated Host authority after lower-casing host and normalizing default port 443. Reject absent/malformed/oversized Origin, userinfo, paths, query/fragment, comma lists, invalid Host or non-443 port. Use bounded host/origin buffers; do not trust forwarded headers. A Referer is not a substitute. Bind challenge/session/ticket to that canonical origin. No arbitrary return URLs.
- Direct IP and `sak-<suffix>.local` are separate host-only cookie origins, even on one device. Login/logout on one does not affect the other's unchanged session; no alias cookie sharing. Both require trusted/accepted device TLS in the browser. Hostname changes do not migrate cookies.
- Lookup/admission, ticket mint/consume and sensitive WS input recheck session liveness plus principal currentness. Extend existing owner-task periodic checks to enforce expiry/revocation for idle sockets, with a planned **at most one-second detection-latency acceptance target**, no new task. This bound is not established by the current loop; implementation and target validation must include lock contention and concurrent load. Mark invalid immediately and request generation-safe close through HTTPD ownership; network delivery/close completion can take longer. Failed notification must not allow more serial input or revive an expired session.
- Logout invalidates only the originating session and its tickets/serial sockets (and later admin sockets); other sessions for the same unchanged account survive. Logout in one tab affects all tabs sharing that cookie. Account password/role/key changes, deletion/recreation and explicit account revocation invalidate all that account's sessions/tickets/sockets across origins, not unrelated accounts. Preserve distinct account, web-session and transport generations. Wipe retired records; late cleanup cannot close reused slots.
- All login/session/auth errors, protected documents, API and ticket responses use `Cache-Control: no-store`; preserve nosniff, no-referrer, frame denial and restrictive CSP. Existing immutable vendored asset policy can remain; no auth-dependent data in those assets. Count response headers against HTTPD's eight-slot default, including Set-Cookie (two on login); adjust only if the exact count requires it. Never log cookies, CSRF, ticket query strings, passwords, verifier or private-key material.
### Browser recovery behavior
- `401`: stop polling/reconnect work, close local socket, clear in-memory CSRF, navigate once to `/login`. On login submission, `401 invalid_credentials` stays on the form with a generic usable error; it must not reveal account existence. No `WWW-Authenticate`, cached Basic header authorization or browser credential-reset instructions after cutover.
- `403`: report CSRF/origin failure without retrying a mutation automatically; allow challenge/session reload and explicit retry. `400`/`413`/`415`: show input/request error. `409`: offer return to app/logout. `429`/`503`: display bounded retry information and do not retry credentials automatically.
- Transport/network error: show connection failure without claiming logout succeeded. A lost logout response can follow successful invalidation: check session status when reachable; `401` confirms login is needed. Do not rely solely on client cookie deletion (HttpOnly).
- Refresh/back navigation validates `/api/session` before ticket/reconnect; expired cookies, reboot and previously Basic-authenticated profiles must all land on usable login. Explicit Disconnect still pauses reconnect while leaving login valid. Capacity and network failure must not look like bad credentials. Cancel pending work on logout/page exit and ignore late responses via generation checks.
## Runtime measurements, reserve gates and exact target checklist
**The user-provided settled-boot and concurrent-session samples below form the 8D.0 runtime memory baseline. Other workload measurements remain pending, not zero.** `memory` reports free/minimum-free/largest-block for internal 8-bit, internal DMA and PSRAM. Record all nine values at every point. DMA overlaps internal heap: do not sum them. Minimum-free is the conservative sum of per-region lifetime minima, not a synchronized low-water sample. Only SSH currently exposes stack minimum-free via `ssh status`; HTTPD/web/broker/admin/UART task margins lack instrumentation and remain pending (no telemetry changes in 8D.0).
| Workload point | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|---|---|---|---|---|
| **Settled clean boot, ~60 seconds, Wi-Fi STA connected, mDNS running (8D.0, user-provided)** | **68,036 / 67,192 / 31,744 B** | **60,280 / 59,436 / 31,744 B** | **8,198,508 / 8,188,800 / 8,126,464 B** | **18,464 B** |
| **Basic-authenticated browser/status, browser serial explicitly disconnected, UART service running; fresh settled boot (user-provided)** | **65,340 / 60,088 / 31,744 B** | **57,584 / 52,332 / 31,744 B** | **8,212,812 / 8,158,868 / 8,126,464 B** | **18,464 B** |
| **Basic-authenticated browser/status, browser serial explicitly disconnected, UART service stopped (user-provided)** | **69,988 / 60,280 / 31,744 B** | **62,232 / 52,524 / 31,744 B** | **8,223,088 / 8,158,832 / 8,126,464 B** | **18,464 B** |
| **Browser/status sample: one active browser serial writer, no SSH sessions (user-provided; disconnected label unconfirmed)** | **60,980 / 55,880 / 31,744 B** | **53,224 / 48,124 / 31,744 B** | **8,163,616 / 8,109,644 / 7,995,392 B** | **18,464 B** |
| **Updated baseline: one browser serial + user SSH + admin SSH (user-provided)** | **44,552 / 17,812 / 31,744 B** | **36,796 / 10,056 / 31,744 B** | **8,187,416 / 8,176,688 / 8,126,464 B** | **16,288 B** |
| **Baseline follow-up: two browser serial + USB + user SSH + admin SSH (user-provided)** | **42,056 / 17,812 / 29,696 B** | **34,300 / 10,056 / 29,696 B** | **8,162,828 / 8,126,736 / 7,995,392 B** | **16,288 B** |
| **After five browser serial disconnect/reconnect cycles, ending connected as user writer (user-provided)** | **60,776 / 59,792 / 31,744 B** | **53,020 / 52,036 / 31,744 B** | **8,163,656 / 8,153,968 / 8,126,464 B** | **18,464 B** |
| **After 15-minute concurrent soak, clients disconnected, then 60-second settled cleanup; UART running (user-provided)** | **65,040 / 17,812 / 31,744 B** | **57,284 / 10,056 / 31,744 B** | **8,212,596 / 8,126,736 / 8,126,464 B** | **16,288 B** |
| Cookie login/logout and browser admin connected | Not implemented | Not implemented | Not implemented | Not implemented |
### Settled clean-boot sample (8D.0 firmware)
The user supplied this transcript on 2026-09-05 after 8D.1 work had begun, explicitly identifying the measured firmware as based on **8D.0**, not 8D.1. Conditions reported: clean boot, settled after approximately 60 seconds, Wi-Fi STA connected and mDNS running. Commands were `memory`, `ssh sessions`, `broker clients`, `ssh counters`, `web counters`, `web status`, then `mdns status`; these are sequential user-provided snapshots, not an atomic sample or an agent-executed test.
- SSH sessions **0/2**; no broker clients connected. HTTPS initialized/running, not transitioning, port 443, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins. Web serial attached, sessions **0/2**, zero tickets. Endpoints reported: `GET /`, `GET /api/status`, `POST /api/ws-ticket`, `WSS /ws/serial`.
- mDNS initialized and announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. This records announcement status, not an independently verified hostname-resolution test.
- SSH counters: lifecycle starts **1**, start-failures/stops/TCP connections/capacity rejects **0**. All handshake, authentication, request-rejection, broker, admin-console and stream counters **0**, including failures, backpressure and revocations.
- Web counters: lifecycle starts **1**, start-failures/stops **0**. Requests total **29**, authenticated **29**, status **29**; auth-failures/root/tickets/assets/response-errors **0**. All ticket, WebSocket session, RX, TX, writer-control and failure counters **0**, including service-start/broker failures and closes.
- The 29 authenticated status requests mean this is a settled boot with HTTPS status activity, not a no-HTTP-traffic idle sample. Browser/version, request origin and polling source were not supplied; do not infer an additional controlled browser-only workload measurement from these counters.
- Follow-up user-provided `ssh status` for settled boot: initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**. Authentication reports role-based password and SSH public key via the user database; admission reports shell/PTY only, with exec, subsystem, forwarding, SCP and SFTP disabled. Owner task on core **1**, configured stack **20,480 B**, stack minimum-free **18,464 B**. This is a settled-boot stack low-water measurement, not a loaded-workload margin or approved reserve floor.
- Exact flashed revision/configuration or binary hash was not supplied. The user's 8D.0 firmware attribution is retained without independently tying this device to the build hash above. This sample does not validate 8D.1, establish reserve floors or measure post-load cleanup; do not treat comparisons with the earlier concurrent samples as controlled per-client allocation deltas.
### Browser/status sample with active serial WebSocket
The user supplied this additional baseline transcript on 2026-09-05 labelled "Basic-authenticated browser/status, serial explicitly disconnected." However, the supplied `web status` reports **one active serial writer**, and counters report one connection with zero disconnects/closes. Preserve the measurements separately; the later disconnected sample below also stops the UART service. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, then `ssh status`, so the snapshots are not atomic.
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoint list. Web serial attached, sessions **1/2**, zero tickets; slot **0**, fd **56**, generation **1**, role-`user` password authentication, broker **8 writer**, no TX pending or closing state. No `broker clients` snapshot was supplied, so other broker-client presence is not established.
- Web lifecycle: starts **1**, start-failures/stops **0**. Requests total/authenticated **28**, root **1**, status **26**, tickets **1**, assets **0**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects **1**, disconnects **0**, connect/service-start/broker failures **0**.
- WebSocket RX: **14** accepted frames / **14 B**, no rejected frames/bytes. TX: **18** binary frames / **753 B**, **2** control frames / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**; send/queue/protocol failures and closes **0**.
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**; all other supplied SSH counters **0**.
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
- This is user-provided baseline evidence, not an 8D.1 target-validation claim. Exact flashed revision/hash, browser/version/origin, settling duration and serial fixture were not supplied. Web request totals are lower than in the settled-boot transcript; same-boot continuity is not established, and no controlled heap delta is inferred. Lifetime minima include prior activity, including the reported serial traffic.
### Browser disconnected, UART service stopped
The user supplied this follow-up 8D.0 baseline transcript on 2026-09-05, reporting that the browser automatically connected serial after login, then was explicitly disconnected and the serial service stopped. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential observations, not an atomic sample or an agent-executed test.
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. This confirms browser serial disconnection; no `broker clients` snapshot was supplied to establish other broker-client presence.
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **26**, root **1**, status **24**, tickets **1**, assets **0**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects/disconnects **1** each; connect/service-start/broker failures **0**.
- WebSocket RX accepted/rejected frames and bytes **0**; TX binary frames/bytes **0**, control frames **2** / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; the zero closes counter is preserved separately from the reported disconnect count.
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
- UART service **stopped**, RS-232 owner **idle**. Configuration v1: **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Phase 0 hardware commands reported available while stopped; none are claimed executed.
- Stopping the UART service changes the allocation workload, so this sample is not a substitute for browser-disconnected memory with the service running. That separate workload is recorded in the fresh-boot sample below. Prior automatic connection/startup can contribute to lifetime minima. Exact build hash, browser/version/origin, settling duration and same-boot continuity with earlier samples remain unspecified; do not infer controlled allocation savings, reserve floors or 8D.1 validation.
### Browser disconnected, UART service running
The user supplied this follow-up 8D.0 baseline transcript on 2026-09-05 after a **fresh, settled boot**, with the UART service running and browser serial explicitly disconnected after login. It fills the intended Basic-authenticated browser/status workload row. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. Exact settling duration was not supplied.
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. No `broker clients` snapshot was supplied to establish other broker-client presence.
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **31**, root **1**, status **28**, tickets **1**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects/disconnects **1** each; connect/service-start/broker failures **0**.
- WebSocket RX accepted/rejected frames and bytes **0**; TX binary frames/bytes **0**, control frames **2** / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; closes and disconnects are distinct reported counters.
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
- UART service **running**, RS-232 owner **serial service**. Configuration v1: **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted: **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
- Lifetime minima include startup and the browser's initial automatic serial connection, not just the disconnected state. This fresh boot is separate from previous samples; no controlled per-connection or service-stop allocation delta is inferred. Exact flashed revision/hash and browser/version/origin remain unspecified. This fills the workload measurement, not reserve-floor approval, serial data-integrity testing or 8D.1 target validation.
### After five browser serial disconnect/reconnect cycles
The user supplied this 8D.0 baseline transcript on 2026-09-05, reporting **five browser serial disconnect/reconnect cycles**. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. The endpoint is **connected**, not settled disconnected cleanup.
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **1/2**, zero tickets. Slot **0**, fd **54**, generation **8**, role-`user` password authentication, broker **64 writer**, no TX pending or closing state. No `broker clients` snapshot was supplied to establish other clients or independently check old-client cleanup.
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **152**, root **3**, status **140**, tickets **8**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **8**, rejected/expired **0**. WebSocket connects **8**, disconnects **7**, connect/service-start/broker failures **0**. These cumulative counts include more activity than the reported five test cycles; no per-cycle snapshots or counter-reset boundary were supplied.
- WebSocket RX **11** accepted frames / **33 B**, rejected frames/bytes **0**. TX **43** binary frames / **8,413 B**, **16** control frames / **1,218 B**. Writer requests/grants **8**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; the zero closes counter is retained separately from seven disconnects.
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
- This records the reported five-cycle endpoint, successful cumulative admissions/writer grants and traffic without reported web failures. It does not prove byte integrity, paused reconnect behavior, observer isolation, per-cycle cleanup or a leak/fragmentation trend. Only the final normal-user role is shown; five cycles per role, including admin-role browser coverage, remain unverified. Exact flashed hash, browser/version/origin, timing and same-boot continuity with prior samples were not supplied. No reserve-floor approval or 8D.1 target validation is inferred.
### Updated one-browser plus user/admin SSH baseline
The user supplied this updated 8D.0 baseline transcript on 2026-09-05 for **one browser serial + user SSH + admin SSH**. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. The superseded original measurement was removed at the user's request.
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **1/2**, zero tickets. Slot **0**, fd **53**, generation **1**, role-`user` password authentication, broker **8 writer**, no TX pending or closing state.
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **2/2**, both public-key authenticated. Admin session **5**, slot **0**, routes to the admin console with no broker client; user session **6**, slot **1**, routes to broker **9 observer**. Both report active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Authentication/admission remains role-based password/public-key, shell/PTY only, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, writer requests **1**, grants **0**, denials **1**, failures/disconnects/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted/rejected **0**, TX **657 B**, I/O failures/session revocations **0**.
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **49**, status **48**, tickets **1**, root/assets/auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects **1**, disconnects/connect failures/service-start failures/broker failures **0**.
- WebSocket RX **19** accepted frames / **19 B**, rejected frames/bytes **0**. TX **39** binary frames / **7,681 B**, **3** control frames / **237 B**. Writer requests/grants **1**, denials/releases/revocations **0**; send/queue/protocol failures and closes **0**. The SSH writer denial is consistent with the browser retaining writer ownership.
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
- No `broker clients` snapshot was supplied, so the transport snapshots establish the browser writer/user SSH observer but not the absence of additional broker clients such as USB. Exact flashed revision/hash, browser/version/origin, uptime, settling/soak duration and same-boot continuity with earlier samples remain unspecified. This records concurrent admission, traffic counters and an SSH stack low-water measurement, not byte integrity, a leak trend, reserve-floor approval or 8D.1 validation.
### Follow-up full-client-mix sample
The user supplied this replacement 8D.0 baseline transcript on 2026-09-05 for **two browser serial + USB + user SSH + admin SSH**, including broker/USB snapshots and requesting that it overwrite the previous measurement. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`; these are sequential observations, not an atomic sample or an agent-executed test.
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **2/2**, both public-key authenticated: admin session **5**, slot **0**, admin-console route with no broker; user session **6**, slot **1**, broker **9 observer**. Both active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Role-based password/public-key authentication and shell/PTY-only admission remain reported, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, writer requests **1**, grants **0**, denials **1**, failures/disconnects/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted/rejected **0**, TX **17,312 B**, I/O failures/session revocations **0**.
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **2/2**, zero tickets, both password-authenticated normal-user sessions for the same account. Slot **0**, fd **56**, generation **2**, broker **16 writer**; slot **1**, fd **57**, generation **1**, broker **10 observer**. Neither slot TX-pending or closing.
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **181**, root **2**, status **175**, tickets **3**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **3**, rejected/expired **0**. WebSocket connects **3**, disconnects **1**, connect/service-start/broker failures **0**.
- WebSocket RX **25** accepted frames / **25 B**, rejected frames/bytes **0**. TX **274** binary frames / **41,456 B**, **10** control frames / **823 B**. Writer requests **3**, grants **2**, denials **1**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Writer denials are consistent with observer admission, not reported transport failures.
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
- Broker lists exactly four clients: web **16 writer** (`web-0-2`), SSH **9 observer** (`ssh-1-1`), web **10 observer** (`web-1-1`), USB **11 observer** (`usb-cdc`), all with **zero pending bytes/events**. Admin SSH is not a broker client.
- USB CDC initialized/attached, host-open **yes**, host DTR/RTS **yes**; broker client **11 observer**. Last host line coding **38400 baud, 8N1** is reported only and does not change UART1's **460800 baud, 8N1** configuration. This confirms USB attachment/admission, not USB byte-integrity testing. Both browsers are normal-user sessions, so admin-role browser login remains unverified.
- Exact flashed revision/hash, browser/version/origin, fixture, durations and same-boot continuity remain unspecified. This records concurrent network admission and traffic, not serial byte integrity, a timed soak, controlled per-client allocation costs or 8D.1 validation. Internal/DMA minima **17,812 / 10,056 B** are observed lows, not approved reserve floors; DMA overlaps internal heap.
### Settled post-soak cleanup
The user supplied this 8D.0 baseline transcript on 2026-09-05 and subsequently confirmed the timing: **15-minute soak**, then client disconnection, then **60 seconds of settled cleanup** before running the commands. This fills the timed soak/cleanup memory row. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`; these are sequential user observations, not an atomic sample or an agent-executed test.
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**. SSH role-based password/public-key authentication and shell/PTY-only admission remain reported, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
- Broker reports **no clients connected**. USB CDC initialized/attached but host-open **no**, DTR/RTS **no**, broker disconnected. Last host line coding **9600 baud, 8N1** is reported only; UART1 remains independently configured at **460800 baud**. Physical USB attachment therefore remains, without an active broker client.
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects/disconnects **1** each, writer requests **1**, grants **0**, denials **1**, failures/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **5 B**, rejected **0**, TX **84,546 B**, **I/O failures 1**, **session revocations 1**. These two nonzero counters are retained without attributing them to normal disconnect or a defect; the transcript does not establish their cause.
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **527**, root **2**, status **521**, tickets **3**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **3**, rejected/expired **0**. WebSocket connects/disconnects **3** each, connect/service-start/broker failures **0**.
- WebSocket RX **25** accepted frames / **25 B**, rejected frames/bytes **0**. TX **1,490** binary frames / **177,163 B**, **11** control frames / **918 B**. Writer requests **3**, grants **2**, denials **1**, releases/revocations **0**; send/queue/protocol failures and closes **0**.
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
- Relative to the recorded full-client-mix snapshot, free internal/DMA heap is **22,984 B higher** and free PSRAM **49,768 B higher**; largest blocks return to **31,744 B internal/DMA** and **8,126,464 B PSRAM**. Lifetime minima are unchanged. These endpoint observations show headroom recovery, not a controlled per-client allocation measurement or proof of no leak. Relative to the fresh-boot browser-disconnected/UART-running sample, free internal/DMA heap is **300 B lower** and PSRAM **216 B lower**, with equal largest blocks; differing HTTP activity and uncontrolled allocation state prevent treating those differences as a diagnosed leak.
- All serial transport clients are disconnected, but cumulative authenticated status requests are present; this is not evidence of no HTTPS polling or retained TLS connections. Exact flashed hash, browser/version/origin, fixture and traffic pattern/verified byte counts remain unspecified. The 15-minute soak and 60-second cleanup durations are user-confirmed; the reported UART configuration is **460800 baud**, not the checklist's separate **115200-baud** soak workload. Full soak/stress acceptance, SSH counter diagnosis, reserve floors and 8D.1 validation remain pending.
Resource gates:
| Gate | Budget / status |
|---|---|
| 8D.0 firmware allocation/route/task/socket change | **0**; documentation only |
| Baseline internal free/minimum/largest-block reserve floors | **Pending reserve analysis and remaining workload measurements**; the recorded sample is not a safety floor |
| PSRAM reserve and largest-block floor | **Pending**, including TLS/SSH connection and handshake peaks |
| Stack reserve floors | **Pending** reserve analysis; SSH measurements recorded, other owner-task telemetry missing |
| 8D.1 incremental budget | **Actual static delta +672 B**, including 664 B store/lock symbols; no module heap allocation, task, route or socket increase. Basic cache retained. Runtime reserve acceptance still pending; see implementation record below. |
| 8D.2 incremental budget | **Pending** exact generation/session bindings added to four tickets/two slots and cleanup integration |
| 8D.3 incremental and cumulative M1 budget | **Pending** four challenge records, throttle, five handlers, parser/CSRF scratch, minus removed Basic cache/key; no new task/socket allowance |
| M2 admin allocation/socket budget | **Pending** retained-serial admission design and target M1 measurements; not approved here |
To close these gates, record each chunk's actual linked/static and allocated structure sizes, internal-versus-PSRAM placement and worst-case fallback, then measure peak incremental handshakes/traffic and post-cleanup fragmentation under the same workloads. Set numeric reserve floors sufficient for measured recovery/USB/SSH needs and the largest required allocation, with an explicitly justified margin; subtract these floors before approving incremental/cumulative budgets. A percentage of total RAM or a source payload subtotal is not a reserve. Stop on monotonic leak/declining largest block, watchdog/stack fault, lost lease or inaccessible login.
Target operator checklist (user-provided samples cover status/memory collection and concurrent admission of two web serial clients, USB and user/admin SSH, with traffic counters and broker/USB snapshots confirming four broker clients and USB host DTR. Remaining checks are unverified, and none were executed by the agent):
1. Identify flashed revision/configuration against this record; record browser/version, IP/mDNS origin, Wi-Fi mode, serial framing/flow/baud and attached peer/fixture. Use existing provisioned accounts; do not expose credentials in evidence. Firmware upload is a separate user-controlled action; no erase is required for this documentation task.
2. On UART0 run `memory`, `serial status`, `broker status`, `broker clients`, `usb status`, `web status`, `ssh status` at settled boot and each table point. Record output without secrets. Use `pio device monitor -b 115200` once a port is available; this is interactive, not a completed automated test.
3. In separate browser profiles authenticate an existing admin and user through **current Basic auth**; verify protected assets/status and bidirectional serial bytes, single writer/request/release, and observer isolation. Test direct-IP and mDNS separately where STA mDNS is available. If baseline login fails, stop and diagnose in a separate task, not during auth replacement.
4. Verify native USB UART1 with host DTR, user SSH serial and admin SSH console, while UART0 remains usable. Open two browser serial sockets plus USB, one user SSH and one admin SSH; collect the full-workload row. This is four broker clients and two SSH sessions; admin SSH is not a broker client. Also check the alternative two-user-SSH mix (five broker clients) without increasing any limit.
5. Run **five** explicit browser serial Disconnect/Connect cycles per role, observing paused reconnect, old broker cleanup, writer/observer correctness and memory after each cycle. Baseline has no logout: do not report Basic-cache reset as a logout test. M1 will repeat five login/serial/logout cycles per role instead.
6. Run a **15-minute** concurrent soak at **115200 baud, 8N1, no flow control**, using a safe known peer/fixture and a recorded binary pattern/byte count. Transfer writer ownership explicitly among transports, collect memory/status every minute, and record bytes/drops, latency and any watchdog/reset. Repeat a separately recorded **five-minute 460800-baud** stress run with the same mix; historical drops at that load are not permission for new exhaustion or lease loss. These workloads do not reduce supported baud rates. Record any fixture limitation rather than inventing results.
7. Disconnect network clients and USB DTR, wait 60 seconds and capture cleanup memory/status. Compare free/largest-block recovery and lifetime minima with boot/full-load rows. Record SSH stack margin; explicitly retain other stack floors as pending until instrumentation is separately scoped.
8. Fill measured reserve/incremental budget cells with justified numbers and review remaining failures. The user explicitly requested 8D.1 with this gate pending; that is not approval to skip later regression checks or M1/M2 milestone pauses.
## 8D.1 implementation and validation record
- **Checkpoint:** Work started from clean `devel` at `02fdeee3453654680c11096c9a6224c26233eced` (documentation-only successor to the build baseline). The user requested 8D.1 after the initial runtime sample and provided the full-client-mix sample while work began. Remaining reserve floors are explicitly pending; this is not an automatic budget approval for subsequent chunks.
- **Implemented:** `src/web_session_store.{h,c}` provides four fixed internal records; 32-byte token digests, 32-byte origin digests, separate 32-byte CSRF state, copied principals, one-hour absolute deadlines and non-reused 64-bit IDs. Issue/lookup/currentness/prune/session and user invalidation APIs exist, plus secret-free counts/storage sizes. No raw bearer token is retained. Failed output and retired record cleanup is explicit. Stop wipes records, cancels in-flight issuance/init and never resets IDs/epochs. No task, module heap allocation or internal-fallback allocation is introduced.
- **Concurrency:** A short portMUX protects only bounded state operations; RNG/SHA/database work is outside it. Resolution rechecks ID/liveness/deadline after principal validation. Issuance rechecks an invalidation epoch before publication; any explicit invalidation conservatively cancels concurrent issuance, including issuance for another account, without removing unrelated live sessions. Epoch/ID exhaustion is fail-closed. Snapshot counts are cumulative for the boot; views containing CSRF are sensitive request-local results, never status snapshots. HTTP Origin validation remains the future caller's responsibility; the primitive only binds a bounded canonical origin digest.
- **Lifecycle integration:** Only an admitted HTTPS start initializes the store; failed starts and accepted stops disable it before cleanup, including failed teardown. Store-init failure is warning-only for the unchanged Basic-auth service. No production caller issues/looks up sessions yet. No login page, cookie route, ticket binding, account-mutation notification hook, new URI, protocol change or generated asset change. Those remain 8D.2/8D.3 scope.
- **Build:** Final `pio run` passed in 8.90 seconds after review fixes, with the same PlatformIO/IDF configuration: **95,204 B linked RAM (+672 B)**, **1,600,505 B flash (+532 B)** relative to 8D.0. Target object symbol inspection reports **656 B state + 8 B portMUX = 664 B**; DWARF confirms **152 B per record × 4 = 608 B**, principal 40 B. The aggregate link delta includes eight additional bytes beyond those store symbols. All storage coexists with the existing Basic cache. No post-change runtime heap claim is made.
- **Dormant-code accounting:** All functions compile and are exercised by the host harness, but the firmware link currently retains only init/stop and their dependencies; unused issue/lookup/etc. code is garbage-collected. The static state is retained in full. Later production callers must account for the newly linked code and stack/crypto execution costs rather than treating this flash delta as the full M1 cost.
- **Focused executable checks:** `python3 tests/web_session_store/run.py` passed using OpenSSL SHA-256 and deterministic RNG/database/time/FreeRTOS doubles. Coverage includes failed init/retry/idempotence, capacity/no eviction, token/origin mismatch, exact expiry/no renewal, per-session/account isolation, stale principals and DB failure, stop/reinit/stale IDs, invalidation during pruning and candidate issuance, replacement during lookup, expiry during revalidation, stop during init, and post-token SHA failure output wiping. RNG/SHA/database doubles assert that no store lock is held. This narrow harness is not a general firmware test suite.
- **Review-only limits:** ID/epoch exhaustion, collision rejection and private record wiping were inspected, not forced by public-API tests. Callback races are deterministic interleavings, not real multicore scheduling. Host tests do not test device DRBG, mbedTLS integration, HTTPD lifecycle races, heap/stack margins or future dormant browser paths. Independent source review found and verified the fix for reinitialization during a rejected concurrent start.
- **Post-change target gate: validated by user sign-off on 2026-09-05.** In addition to the boot and full-client-mix samples below, the user reports a long-lasting command producing output at full **115200-baud line speed with no dropped broker packets**, and explicitly requests 8D.1 validation closure. Exact command, duration and byte/drop-counter totals were not supplied; this is user-reported hardware evidence, not an agent-executed test. Unrecorded detailed lifecycle/HTTPS restart/cleanup checks remain regression coverage, not blockers to this sign-off or claims of execution. Numeric reserve approval and target testing of future cookie-authentication paths are not implied. Stop before 8D.2 until requested.
### 8D.1 target samples: boot and full client mix
Supplied by the user on 2026-09-05 as **Phase 8D.1 validation**. Each transcript runs `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`. These are sequential user observations, not atomic samples or agent-executed tests. Exact flashed revision/hash, boot settling interval, loaded duration, browser/version/origin and serial fixture were not supplied for this phase; the 8D.0 source sign-off does not identify this changed firmware.
| 8D.1 workload | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|---|---|---|---|---|
| Fresh boot, UART stopped, no broker clients | 69,564 / 67,312 / 31,744 B | 61,808 / 59,556 / 31,744 B | 8,223,100 / 8,216,900 / 8,126,464 B | 18,464 B |
| SSH writer + admin SSH + USB observer + two web observers; UART running at 115200 baud | 41,420 / 19,384 / 31,744 B | 33,664 / 11,628 / 31,744 B | 8,162,824 / 8,115,028 / 8,126,464 B | 16,288 B |
- **Both samples:** HTTPS and SSH initialized/running, not transitioning, ports **443/22**, `last-error=ESP_OK`. HTTPS retains HTTP Basic via the user database, four users/two admins and unchanged endpoints. SSH retains role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. SSH owner core **1**, configured stack **20,480 B**. mDNS initialized/announced as `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
- **Boot state:** SSH **0/2**, WebSocket **0/2**, web serial attached, zero tickets, no broker clients. UART stopped, RS-232 owner idle; configuration v1 **115200 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**, RX available/TX pending **0**. Phase 0 commands reported available, not executed. USB initialized/attached, host-open/DTR/RTS **no**, broker disconnected; reported host line coding **9600 baud, 8N1**.
- **Boot counters:** SSH starts **1**, every other supplied SSH counter **0**. Web starts **1**, start-failures/stops **0**; requests total/authenticated/status **9**, root/tickets/assets/auth-failures/response-errors **0**. All ticket, WebSocket session/RX/TX/control/failure counters **0**. Thus the boot sample includes authenticated status activity, not a no-HTTP-traffic idle state.
- **Loaded clients:** SSH **2/2**, public-key user session **5**, slot **0**, broker **8 writer**; public-key admin session **6**, slot **1**, admin-console route, no broker. Both active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Web **2/2**, both password-authenticated normal-user observers for the same account: slot **0**, fd **55**, generation **1**, broker **10**; slot **1**, fd **56**, generation **1**, broker **11**. Zero tickets, neither web slot TX-pending or closing.
- **Loaded broker/USB/UART:** Exactly four broker clients: SSH **8 writer**, USB **9 observer**, web **10/11 observers**, all with zero pending bytes/events. USB initialized/attached, host-open/DTR/RTS **yes**, broker **9 observer**, reported host line coding **38400 baud, 8N1**. UART running, owner serial service, unchanged **115200-baud 8N1** configuration; RX available/TX pending **0**, modem **DCD=0, DSR=1, CTS=1, RI=0**, **VLD=1**. USB line coding does not reconfigure UART1.
- **Loaded SSH counters:** Starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, failures/disconnects **0**, writer requests/grants **1**, denials **0**, **broker revocations 1**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **152 B**, rejected **0**, TX **20,012 B**, I/O failures/session revocations **0**. The broker revocation is preserved as an unexplained cumulative event, distinct from session revocation; the current snapshot confirms SSH writer ownership.
- **Loaded web counters:** Starts **1**, start-failures/stops **0**. Requests total/authenticated **119**, status **117**, tickets **2**, root/assets/auth-failures/response-errors **0**. Tickets issued/consumed **2**, rejected/expired **0**. WebSocket connects **2**, disconnects/connect failures/service-start failures/broker failures **0**. RX accepted/rejected frames/bytes **0**; TX **334** binary frames / **37,242 B**, **5** control frames / **398 B**. Writer requests **2**, grants **0**, denials **2**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Denials are consistent with both browsers being observers.
- **Interpretation:** These samples establish post-change service startup, authenticated status requests and concurrent admission/traffic with the full client mix, without reported SSH I/O or web failures. They do not establish byte integrity, lifecycle/soak completion or target exercise of dormant cookie-session APIs. Boot UART is stopped; loaded UART is **115200 baud** and SSH is writer, unlike the **460800-baud browser-writer** 8D.0 full mix. No controlled incremental heap cost or memory improvement is inferred. DMA overlaps internal heap; lifetime minima and SSH stack low-water marks are not approved reserve floors.
-212
View File
@@ -1,212 +0,0 @@
# Phase 8D — Incremental web administration plan
**Latest implementation (2026-09-08):** User-requested **8D.11 implemented, host-tested/build-verified; target sign-off pending**. Accounts fingerprint listing and Ed25519/P256 import/delete/clear use canonical target-checked APIs and the existing dispatcher. Sparse-slot selection regression fixed; 24 handlers/six sockets, no new task/stack-size/queue expansion. Final build 96,076 B RAM / 1,703,685 B flash. [8D.11 record](phase8d11_implementation.md) contains API/bounds, test attribution and pending hardware checklist. Supersedes historical wait-for-8D.11 instructions below; 8D.88D.10/M2 remain accepted. No M3 completion or 8D.12 work.
**Latest target sign-off (2026-09-08):** User reports thorough Serial parameter display/settings and user/account testing, supplies settled boot/full-client-mix evidence, and explicitly says implemented work can be signed off. **Implemented 8D.88D.10 are accepted**, including 8D.9 UX and both 8D.10 slices. Supersedes target/signoff-pending statements below, not build/test evidence or restrictions. [8D.10 target acceptance record](phase8d10_implementation.md) contains all six loaded samples, client mix, counters and evidence limits. Full mix at 230400 baud confirms SSH sole writer + USB/two web observers with both admin routes. Final internal/DMA free 32,556/24,800 B, lifetime minima 19,228/11,472 B, largest 23,552 B; isolated failures retained without diagnosis. Exact revision/browser/durations/individual cases are unspecified; no leak-free-soak or reserve approval inferred. M2 stands; full M3/browser-shell parity is not claimed. **Next is 8D.11 only when separately requested; this sign-off authorizes no implementation.**
**Current slice 2 completion (2026-09-08):** **8D.10 implementation is complete, host-tested/build-verified, not target accepted; target validation/full signoff remain pending.** Create/password/self workflows use bounded 768-byte admission and periodic credential cleanup (30-second deadline plus one-second timer/scheduling latency); admitted executing work is not cancelled. Protected generation is separate before commit, with no retained retrieval. Self revocation may prevent results: 401/disconnect is uncertain, never grounds for automatic retry. Browser-shell restrictions remain unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage, **23 handlers/six sockets**. Parent PASS canonical accounts/boundary, parser **294**, cookie accounts **9 plus shared**, serial-settings **10**, transport **25**/tickets **12**, store/serial and diff check. UI agent **57 plus CSP** and route agent lifecycle **15** pass; these are not claims of the parent's additional UI/lifecycle reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. Timer runtime costs and heap/stack margins remain unmeasured. [Current 8D.10 record](phase8d10_implementation.md) contains contracts/evidence/target checklist. No sanitizer validation, assets/device/commit/8D.11 work, M2 reopening, prior-phase signoff or reserve approval inferred.
The implementation/continuation entries below are historical evidence. In particular, slice 1's exclusions, next-slice instruction, 22-handler count and build figures do not describe current slice 2.
**Latest implementation (2026-09-08):** User-requested **8D.10 first slice is host-tested/build-verified; target pending and phase incomplete**. Pre-edit split follows the row below: Accounts list and other-account role/delete now implemented, with conditional target identity checks, existing dispatcher/target notifications and bounded automatic UI completion. Three optional routes, **22 handlers/six sockets**, **95,828 B RAM / 1,684,357 B flash**. [8D.10 record](phase8d10_implementation.md) covers tests/resources/limits. Next is the second 8D.10 slice (create/password/generated-secret/self workflows), not 8D.11. Supersedes historical stop-before-8D.10 instructions; no prior target signoff, M2 reopening or reserve approval inferred. Final 8D.9 UX baseline is recorded in [8D.9](phase8d9_implementation.md).
**Latest continuation (2026-09-07):** Separately authorized **8D.9 is implemented / reviewed / host-tested / build-verified**, with 8D.8/8D.9 target acceptance still pending. [8D.9 record](phase8d9_implementation.md): typed Serial framing/lifecycle/persistence, one session-bound operation slot on the existing dispatcher, manual bounded result recovery, 19 handlers/six sockets; final **95,708 B RAM / 1,666,725 B flash**. No new task/queue depth/stack or broker writer semantics. This supersedes older stop-before-8D.9 instructions, not M2 signoff or deferred restrictions, admission followups or unapproved reserves. Stop before separately requested 8D.10; no target signoff inferred.
**Previous implementation (2026-09-07):** Separately user-authorized **8D.8 is implemented / host-tested / build-verified**, with target/browser validation and new phase signoff pending. [8D.8 record](phase8d8_implementation.md): read-only admin Settings/Serial, nonblocking typed snapshot, 17 handlers/six sockets, final 95,580 B RAM / 1,654,529 B flash; exact tests/resources/limits and target checklist recorded. This supersedes older next-8D.8/wait-for-request instructions below, not M2 signoff or evidence. Deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups remain. Stop before separately requested 8D.9; no new signoff is inferred.
Status: **8D.08D.6 and M1 validated by explicit user sign-off. 8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07 ("Jupp, sign M2 off"), superseding historical M2-open, target-pending and continuation instructions below without requiring revalidation. Full browser parity is not claimed: self/generated/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed. Numeric memory reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation.** See the [8D.7/M2 sign-off and evidence](phase8d7_implementation.md), [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md).
This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist.
## Why this phase is split
The previous all-in-one attempt was rolled back from `devel`. The user reports a roughly +10k/-1k-line change, repeated agent/time/context interruptions, incomplete validation, broken browser login, and severe memory pressure. These are reported symptoms, not a diagnosed root cause. The separate experimental branch is not the implementation baseline: do not merge/cherry-pick it wholesale or copy its abstractions without a separately scoped review.
Deliver three independently useful milestones before attempting feature completeness:
1. **M1: reliable browser login/logout and the existing serial UI** — 8D.08D.3.
2. **M2: browser admin shell alongside uninterrupted serial access** — 8D.48D.7.
3. **M3: guided administration, one settings domain at a time** — 8D.88D.21.
8D.22 is final integration acceptance, not the first time anyone builds or tries the firmware. Stopping after M1 or M2 is valid incremental delivery, but does not mean all of Phase 8D is complete.
## Work-unit rules
- **One numbered chunk per implementation request.** Do not interpret “continue Phase 8D” as permission to implement all remaining chunks. Select the first unblocked chunk and state its scope before editing.
- Each chunk should fit one normal agent session, including review, a bounded build, focused validation, and handoff. Plan for roughly 6090 minutes of implementation and reserve at least the final third for validation/documentation. These are scope limits, not runtime guarantees; split further before coding if the estimate does not fit.
- Aim for a few hundred changed authored lines in a small source set. An expected change above roughly 600800 authored lines, more than one new task, or several independent subsystem changes triggers a scope review and another split. This is not an incentive to compress code, omit tests, or hide generated changes.
- Declare allowed files, behavior changes, explicit exclusions, resource deltas, and acceptance checks first. Source sets below are starting points, not permission to refactor every listed module. New files must have a narrow responsibility justified by that chunk.
- Build the smallest complete increment. Internal preparatory chunks may leave unused interfaces, but must not expose half-protected routes. Do not ship an intermediate cookie-authentication route without CSRF/origin protection, currentness checks, expiry, and logout cleanup.
- Reuse subsystem APIs, the existing HTTPD ownership model, the canonical command dispatcher, and authored UI assets. Do not introduce a generic web framework, CLI-over-HTTP endpoint, second command registry, second dispatcher, task-per-request model, or speculative settings infrastructure.
- Do not rename/extract the entire `admin_ssh_console` module just to give it a generic name. Adapt the smallest necessary boundary and retain existing SSH callers. Do not regenerate `src/web_assets_data.*` or update vendored xterm dependencies as incidental work.
- Add focused counters and checks with the feature that needs them, not as a final observability project. New tests must be local/bounded and document their exact command; this repository currently has no automated host test command. Avoid scaffolding a general test platform as part of implementation.
- Run `pio run` with a finite tool timeout. A timeout is **incomplete validation**, not a pass; record it and stop rather than starting overlapping/repeated build jobs. Fix only failures caused by the chunk.
- End with a reviewed diff and handoff: implemented behavior, build result, memory delta, checks actually run, hardware checks pending, and exact next chunk. Keep `docs/agent/current-state.md` current. Never include credentials, cookies, CSRF values, tickets, or verifier material in evidence.
- A chunk is **implemented / build-verified / target-verified** as separate states. If device access is unavailable, provide the small manual checklist and mark it blocked for target validation. Do not proceed past an M1/M2 gate, or stack further runtime-changing chunks on an unverified predecessor, without an explicit user decision. Never silently promote documented tests to passed tests.
- No automatic commits, branch changes, uploads, erase, NVS migration, or imports from the abandoned branch. The user chooses commit/revert checkpoints; preserve independently reviewable diffs.
## Baseline and resource gates
8D.0 must record the actual baseline revision and configuration before resource budgets become acceptance criteria. The current memory notes report 94,532 bytes linked RAM and 1,599,765 bytes flash for an mDNS-enabled build; these are historical reference values, **not a fresh measurement or sufficient runtime headroom**.
For every chunk that changes allocation, concurrency, or routes:
- Inventory static internal RAM, normal and worst-case internal heap, PSRAM, task count/stack sizes, request/response scratch buffers, queue depth, HTTPD handlers, sockets/TLS connections, and session/ticket capacity. Record limits before increasing any of them. Cookie sessions, serial sockets, admin sockets, and outstanding tickets are different resources.
- Use UART0 `memory` to record internal/PSRAM free, minimum-free, and largest-block values at the same defined workload points: settled boot, login, serial connected, admin connected when available, full supported concurrent workload, and after repeated close/logout/reconnect. Compare both each chunk and cumulative growth against the baseline. Collect relevant task stack high-water marks where available; explicitly record missing instrumentation.
- Set numeric internal-heap/largest-block/stack reserve floors and per-chunk incremental budgets from baseline measurements and actual allocation sizes in 8D.0. No invented “safe free heap” constant and no percentage-of-total-RAM substitute. If the floor cannot be measured, mark the gate pending rather than guessing.
- Account for **two simultaneous browser WebSockets** when serial and admin coexist, plus HTTPS requests and existing SSH connections. Capacity rejection must be explicit and must not silently evict an active serial client/writer to admit an admin socket. Do not merely raise HTTPD/lwIP limits until a page happens to work.
- Prefer bounded PSRAM payload storage only where cache-disable/lifetime constraints allow it; retain required internal control structures/stacks. Record fallback behavior: opportunistic internal fallback must not consume the recovery reserve unnoticed. Optional web-admin allocation failure must leave current serial, UART0, USB, and SSH paths usable.
- Do not accept a monotonic heap leak, declining largest-block trend, watchdog/stack fault, unexplained reserve-floor violation, or unrecoverable login failure. Stop and fix/split the current chunk instead of borrowing from future budgets. Supported baud rates and client capacities must not be silently reduced to pass.
### Always-on regression smoke check
After each runtime-changing chunk: build; boot and read UART0 status/`memory`; log in through the currently supported browser authentication path; open serial, explicitly disconnect and reconnect; exercise native USB UART1 access and existing user/admin SSH routes. Add the chunk-specific checks below. Use a fixed small repetition count selected in 8D.0 for routine lifecycle checks and a longer bounded soak at milestone gates; record actual counts and durations, not just “stable.”
## M1 — Authentication without changing the rest of the UI
### 8D.0 — Baseline, browser contract, and resource budget
**Scope:** Documentation and measurement only. Read the relevant `web_server`, `web_serial_transport`, `web_ui`, user-principal, and memory-reporting paths. Confirm current login/serial operation on rolled-back `devel`; no investigation of the experimental branch is required.
**Deliver:** A short baseline record, measured resource table, supported concurrency/socket budget, and exact M1 browser contract: public login assets; protected routes; session/ticket capacities and lifetimes; absolute/idle expiry rules; capacity rejection; cookie renewal; pre-login CSRF bootstrap; strict origin policy; no-store responses; logout-versus-account-wide revocation; and browser error handling. Preserve mDNS and direct-IP access as separate host-only cookie origins. Choose the simplest bounded policy, with no Basic compatibility path by default. Explicitly list the few authentication request/response fields rather than designing all future settings APIs.
**Gate:** Existing admin and user browser login, serial data, USB, and SSH work; baseline `pio run` and target memory evidence are recorded. If the rolled-back baseline already fails login, diagnose that in a separate task before changing authentication. If target evidence is unavailable, the budget and runtime gate remain pending.
**Record:** [8D.0 baseline, browser contract, resource inventory and target checklist](phase8d_baseline.md). **Validated by user sign-off on 2026-09-05**, with tested source identified as `d8999cd4a96e477fabd392ced02d810c3cd22d0f`. Historical baseline build: 94,532 B linked RAM / 1,599,973 B flash. User-provided boot, browser, lifecycle, mixed-client and 15-minute soak/60-second cleanup samples include heap and SSH stack measurements. The user attributes SSH I/O errors to out-of-spec 460400-baud testing; see sign-off for distinction from reported UART configuration. Numeric reserve floors and incremental budgets remain open, without blocking user-approved 8D.0 closure. Do not treat the documented contract as implemented behavior.
### 8D.1 — Bounded server-side session primitives, not yet browser-facing
**Start in:** `src/web_server.{c,h}`, `src/user_database.h`, `src/secure_random.h`; add a narrowly owned session module only if needed.
**Scope:** Fixed-capacity session issue/lookup/expiry/invalidation with digest-only token storage, copied principal and authentication-generation binding, CSRF state, and secret-free capacity/expiry/invalidation counters. Decide ownership/locking explicitly because future console revocation is not necessarily on the HTTPD task. Wipe transient secrets and use existing secure randomness. No login page, HTTP auth cutover, admin route, new permanent task, or settings work.
**Gate:** Focused local/component checks for lifecycle, capacity, slot reuse, stale principals, and failed initialization; bounded storage accounting and build. Existing Basic-auth behavior remains unchanged. If no executable harness is practical, distinguish code review from target execution and carry the missing checks into 8D.3; do not claim unused code was exercised by a boot test.
**Record:** Implemented `web_session_store` plus admitted-start/stop lifecycle hooks; no route uses it yet. `python3 tests/web_session_store/run.py` and `pio run` pass. Linked RAM **95,204 B (+672 B)**, flash **1,600,505 B (+532 B)** versus 8D.0; static store/lock symbols total 664 B, no module heap allocation/new task/routes/sockets. Only init/stop are currently retained in the firmware link; host tests exercise the full production module. See the [8D.1 implementation record](phase8d_baseline.md#8d1-implementation-and-validation-record) for exact accounting, review limits and target evidence. **Target validated by user sign-off on 2026-09-05**, following boot/full-client-mix samples and a reported long-lasting command at full 115200-baud line speed with no dropped broker packets. Numeric reserve gates remain open. Stop before 8D.2 until requested.
### 8D.2 — Bind existing serial tickets and sockets to a web-session identity
**Start in:** `src/web_serial_transport.{c,h}`, `src/web_server.{c,h}`, the session module from 8D.1, and the existing revocation call sites in `src/user_console.c`.
**Scope:** Add generation-safe originating web-session identity to serial tickets/slots, specific-session cleanup, and account-wide invalidation hooks. Keep web-session identity distinct from account authentication generation and transport slot generation. Preserve authoritative currentness checks when best-effort notification fails. Keep the current Basic path working until the atomic cutover; no public cookie-auth route yet.
**Gate:** Build and existing serial regression. Exercise session-specific versus account-wide cleanup through focused checks where available: one session's logout must not disconnect another session for the same unchanged account; account mutation must invalidate all affected account sessions/tickets. Stale cleanup cannot close a reused slot. Record any dormant paths that require the next chunk's browser validation.
**Record:** [8D.2 implementation, accounting and target checklist](phase8d2_implementation.md). Distinct session IDs now bind tickets/slots; internal cleanup and account-notification hooks are present, while Basic remains the only public authentication path. Both focused host modes and `pio run` pass: **95,260 B linked RAM (+56 B)**, **1,601,925 B flash (+1,420 B)** versus 8D.1. No capacity/task/route/asset changes. **Target validated by user sign-off on 2026-09-05**, following post-flash boot and full-client-mix samples. Sanitizer execution is blocked by missing host runtime libraries; numeric reserve gates remain pending. Stop before 8D.3 until separately requested.
### 8D.3 — Atomic login/logout cutover with the unchanged serial application
**Start in:** `src/web_server.{c,h}`, `src/web_ui.{c,h}`, session primitives, and the session-bound serial integration.
**Scope:** Minimal same-origin login page, session status, explicit logout, bounded login throttling, and cookie-based authorization of the existing app/status/ticket routes. Use a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and explicit lifetime. Enforce the agreed pre-login CSRF protection plus strict origin checks on login, and session CSRF plus strict origin checks on every authenticated mutation including logout and ticket issuance. Keep passwords/tokens transient, preserve security headers/CSP, update the loader hash atomically if it changes, and handle JSON/text safely. Remove browser Basic challenges/cache authorization so stale credentials cannot bypass logout. No admin shell, settings, visual redesign, or new serial protocol.
**Gate — M1 (mandatory target/browser pause):** Both roles log in; incorrect credentials give a usable error; logout and account switching need no browser credential-cache reset. Refresh/reboot/expiry return to a usable login; no redirect loop or missing login asset. Test fresh and previously Basic-authenticated browser profiles, direct IP and mDNS where available, bounded capacity/backoff, CSRF/origin rejection, current-session logout cleanup, password/role/key changes and deletion/recreation via UART0, and unrelated-session/account isolation. Existing serial data, writer ownership, and explicit reconnect still work. Record memory before/during/after repeated login/serial/logout and simultaneous SSH/USB operation. Stop here for user confirmation before M2.
If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering or private request-parsing helpers. Do not split the live security cutover into an insecure intermediate deployment.
**M1 sign-off (2026-09-06):** User explicitly completed M1 after successful both-role login, full-client-mix operation and post-soak telemetry. See [sign-off/evidence](phase8d3_implementation.md#m1-validation-sign-off-and-post-soak-evidence-2026-09-06). Minima/largest blocks are unchanged from the earlier loaded sample, with no reported transport failures. Clients remain connected; exact soak duration/revision and disconnected cleanup are not claimed. Numeric reserves stay open; unrecorded detailed checks are evidence limitations, not blockers to the user-approved milestone closure. **Wait for a separate 8D.4 request.** Older checkpoint notes below are historical.
**Live cutover checkpoint (2026-09-05):** [Implementation, HTTPD boundary, resource accounting and M1 handoff](phase8d3_implementation.md). Cookie login/logout replaces Basic for app/status/ticket routes; explicit pre-101 admission and strict header/Origin/CSRF policy use an isolated version-checked private IDF adapter, **not an SDK patch**. Resumed another agent's implementation and fixed pending-buffer wiping to preserve right-aligned unread data. All five focused suites and `pio run` pass. Final **95,508 B RAM / 1,625,689 B flash**, +248/+23,764 B versus 8D.2. No hardware/browser execution or reserve-floor approval. **Stop for mandatory M1 acceptance before 8D.4.** The following preparatory records are historical, superseded for current implementation status.
**Preparatory split (2026-09-05):** Scope review selected private request parsing first; the complete challenge/throttle/route/browser/test change exceeds the authored-line work-unit target. Added allocation-free `src/web_auth_parse.{c,h}` with no live HTTP callers: bounded canonical same-origin comparison, unique cookie extraction and strict UTF-8 login JSON decoding. `python3 tests/web_auth_parse/run.py` passes **268 cases** against production C; both existing session-store host modes pass. Final `pio run` passes in **7.50 seconds** and reports **95,260 B RAM / 1,601,925 B flash**, unchanged from 8D.2 because helpers are not live linked paths. No route/task/socket/stack-size/asset changes. No target/browser validation, runtime reserve approval or M1 completion is implied. Continue within **8D.3**, with the full atomic cutover still pending; see `docs/agent/current-state.md` for exact integration obligations.
**Login-renderer preparatory split (2026-09-05):** Added `src/web_login_ui.{c,h}` with no live caller/route, leaving Basic and existing serial UI unchanged. Standalone 7,387-byte HTML plus terminator has no protected asset dependencies, five security headers including no-store and exact script-hash CSP. Explicit-only challenge/login flow, bounded request/response handling, safe errors/manual backoff, disabled pending inputs, best-effort password/reference cleanup and generation-safe page-exit cancellation. Review fixes abort every attempt on exit (including unread error bodies) and clear re-entered passwords. `python3 tests/web_login_ui/run.py` passes production C rendering/failure checks, exact CSP hash and eight Node VM groups; parser and both session test modes also pass. Final `pio run` passes in **8.25 seconds**, unchanged **95,260 B RAM / 1,601,925 B flash**: unused renderer costs are not live-linked/runtime costs yet. No new task/socket/route/stack-size/module heap or generated-asset change. No real-browser/HTTPD/hardware validation or M1 completion. **Next is the atomic live 8D.3 cutover using both prepared pieces**, followed by the mandatory M1 target gate; no additional login-rendering split is needed.
## M2 — Reuse the admin shell, then expose it
### 8D.4 — Small transport-neutral console boundary
**Implementation checkpoint (2026-09-06):** [8D.4 implementation, resource accounting and target sign-off](phase8d4_implementation.md). Implemented / host-tested / build-verified / validated by explicit user sign-off after boot/full-client-mix evidence and successful empty Enter and soak testing. Owner callbacks and transport-qualified identity retain the existing two shared slots and SSH API, with no browser routes. Build **95,084 B RAM / 1,627,173 B flash**, **-424 / +1,448 B** versus recorded latest 8D.3. Numeric reserves remain open; unrecorded detailed checks do not reopen this user-approved closure. Wait for a separate 8D.5 request.
**Start in:** `src/admin_ssh_console.{c,h}`, `src/console_input.{c,h}`, and only the necessary `src/ssh_transport.c` callers.
**Scope:** Introduce the minimal transport identity/output/lifecycle boundary needed by a future web frontend. Retain the single dispatcher, fixed queue, line editing/history/completion/prompts, currentness checks, generation tokens, and existing SSH API compatibility where practical. Do not duplicate per-SSH buffers for hypothetical web capacity or rename the whole module. No web endpoint or second dispatcher/task.
**Gate:** UART0/admin-SSH serialization, hidden prompts, completion/history, deferred actions, disconnect/revocation with queued work, and slow-output behavior regressions pass. Memory delta is explained before adding browser slots. This refactor must stand alone and leave behavior unchanged.
### 8D.5 — Bounded admin WebSocket backend, no normal UI entry yet
**Target sign-off (2026-09-06):** User explicitly validates 8D.5 after settled cold-boot telemetry and successful 15-minute full-client-mix active-use soak at **230400 baud**, reporting a few broker drops under extremely fast/dmesg output. [Evidence and limitations](phase8d5_implementation.md#target-sign-off-2026-09-06). This supersedes the older pending acceptance/checkpoint notes below. No zero-drop claim or loaded/cleanup telemetry is inferred. Numeric reserves remain open; missing detailed results do not reopen signed-off 8D.5. Wait for a separate 8D.6 request; M2 is not yet complete.
**Combined backend checkpoint (2026-09-06):** User authorized finishing all of 8D.5, superseding the prerequisite-only pause below. Backend, shared-console allocation, protected admission, revocation, fail-before-side-effect restrictions and local/manual test tooling are **implemented / host-tested / build-verified**. All relevant host suites pass, including real cookie/store/ticket/transport endpoint integration. Parent reports the sequential final `pio run` after the HTTPD-owned shutdown/reuse fix passed at **95,580 B RAM / 1,637,273 B flash**, **23.55 s**. Deltas: **+416/+9,224 B** versus prerequisite, **+496/+10,100 B** versus 8D.4, **+1,048/+37,300 B** versus 8D.0. Final independent security integration review reported no actionable findings. **Target runtime/socket measurements, numeric reserves and acceptance remain pending.** No device operation or 8D.6/UI/M2 completion. Details and historical build evidence: [implementation record](phase8d5_implementation.md). 8D.6 onward remain separately requested work.
**Preparatory checkpoint (2026-09-06):** [8D.5 prerequisite, validation and handoff](phase8d5_implementation.md). Resumed existing uncommitted console-owner currentness/prompt cleanup work; reviewed and extended production-publication tests. Both console suites and `pio run` pass: **95,164 B RAM / 1,628,049 B flash**, **+80 / +876 B** versus recorded 8D.4. No routes/tasks/sockets/UI added. Work-unit review keeps the live backend in the next increment within 8D.5; backend/M2 remain incomplete. Target regression or explicit user decision is needed before stacking runtime changes; numeric reserves remain open.
**Start in:** The console boundary from 8D.4, `src/web_server.{c,h}`, and a small web-admin transport adapter as justified. Reuse existing HTTPD scheduling patterns without mixing admin data into the serial transport.
**Scope:** Admin-only, short-lived single-use tickets bound to both current web session and principal; bounded console admission/input/output; session expiry/logout/revocation cleanup. HTTPD owns socket work and the dispatcher owns command execution. No broker client for this route. An absent UI is not authorization: every ticket, upgrade, and sensitive operation is checked on the server. For self-affecting web actions not safely supported yet, explicitly reject before side effects and list the temporary restrictions for 8D.7.
**Gate:** Focused authenticated test-client or temporary local development-page checks (not a shipped debug endpoint): user-role rejection, admin command/output, prompt/backpressure, stale ticket/slot rejection, cleanup, and concurrent UART0/admin SSH. Admission failure does not remove the serial writer. Build and measure actual socket/console-slot cost. No generic HTTP command runner.
### 8D.6 — Browser terminal selector and serial-lease preservation
**Target sign-off (2026-09-06):** User confirms the remaining validation checks after 60-second boot/full-client-mix/partial-cleanup telemetry, and closes 8D.6. [Evidence and handoff](phase8d6_implementation.md#target-sign-off-and-evidence-2026-09-06). Full mix at **230400 baud** includes browser/admin SSH and four serial broker clients with one web writer. Internal/DMA lifetime minima **6,516 / 1,580 B** remain a numeric-reserve follow-up, not a claim of exhaustion or grounds to reopen sign-off. Exact flashed revision/browser/repetition counts/soak duration were not separately supplied. Latest cosmetic toolbar build passes in **7.60 s**, unchanged **95,580 B RAM / 1,646,489 B flash**, with 17 UI groups/CSP checks passing. Supersedes older pending notes below. Wait for separately requested **8D.7**; M2 remains open.
**Implementation checkpoint (2026-09-06, updated after review fixes):** [8D.6 implementation, accounting and target checklist](phase8d6_implementation.md). Implemented / host-tested / build-verified; target validation and parent re-review of fixes pending, no phase sign-off or M2 completion. Both P2 findings fixed: session identity changes require a clean document before adopting a new view, and fit caches only successful measurements with three bounded readiness retries. Sixteen production-rendered UI groups and focused UI/auth/store reruns pass; prior admin/console regression results remain recorded. Final finite `pio run`: **21.35 s, 95,580 B RAM / 1,646,489 B flash**, **0 / +9,216 B** versus 8D.5; cumulative **+1,048 / +46,516 B** versus 8D.0; review-only delta **0 / +1,376 B**. No backend/capacity/8D.7 restriction/asset change. Numeric reserves remain open; prior sign-offs stand. Stop for validation/user decision before separately requested 8D.7. This supersedes older planned-8D.6 status text in this document.
**Start in:** `src/web_ui.{c,h}`, using the completed serial/admin protocols.
**Scope:** Add admin-only Serial/Admin selection and separate bounded terminal state; no new settings. Keep serial connected and drained/observed while hidden. Keep writer/observer identity and Request control/Release control visible in both modes. Mode changes route displayed output and keyboard input only. Closing the admin route or shell `exit` leaves serial intact; explicit serial Disconnect retains its documented cleanup/reconnect behavior. Bound scrollback and avoid leaking browser listeners/sockets across switches.
**Gate:** Repeated switches preserve the same broker client ID and writer ID; background serial remains observed, with any bounded overflow visible rather than silently stopping observation. Ordinary users retain serial-only navigation and server-side denial. Exercise hidden prompts, line editing/history/completion, resize, admin-route reconnect, full logout, and expired-session UI. Measure simultaneous two-WebSocket plus HTTPS request headroom; no eviction to make the selector work.
### 8D.7 — Web-shell lifecycle parity and M2 acceptance
**M2 sign-off (2026-09-07):** User explicitly says "Jupp, sign M2 off" after the implemented-scope validation below and discussion of 8D.8 next. M2 is accepted with the deferred restrictions, nonblocking unresolved admission issue and unapproved numeric reserves/stack margins recorded in the current status. No full parity, new checklist passes or revalidation requirement is implied. Next is 8D.8 read-only settings entry and Serial page only upon a separate request; no implementation is authorized by this sign-off alone. The earlier checkpoints and original scope/gate below are historical planning and evidence, not outstanding conditions for M2 closure.
**Historical 8D.7 target sign-off (2026-09-07), before M2 sign-off:** User explicitly requests marking 8D.7 validated after thorough testing. Certificate rotation and web start/stop were verified, with lifecycle via UART0/SSH admin/web admin and restart after browser stop via another route. Full mix without broker drops up to 230400 baud after external adapter baud correction is user-reported. Intermittent supported two serial + one admin admission failures have recently not recurred and are accepted nonblocking, not fixed. [Evidence and limits](phase8d7_implementation.md). No detailed reboot/individual mutation checklist passes are inferred. This supersedes pending status and next-slice instructions in the historical checkpoints below for all three implemented slices, including other-account operations. Remaining self/generated/key/legacy-credential and other owner parity stays deferred/restricted; numeric reserves and M2 acceptance remained open at this earlier checkpoint.
**Second bounded certificate slice:** [Implementation, separate slice histories and pending target checklist](phase8d7_implementation.md). Exact parsed browser `web certificate rotate --force` uses a typed request-queue union and immutable owner `dispatcher_actions` mask: bounded drain/200 ms then nonblocking handoff to the existing 12 KiB dispatcher, not 4 KiB control. Pending input gating, token/principal/session revalidation and executing-slot reservation persist through execution. Transactional certificate commit → stop → start short-circuits errors and retains ownership on failed stop; SSH/UART0 unchanged. No new tasks/depth/routes/assets/stacks; target owner-mask/local-scratch accounting and stack margins unknown, host sizeof is not proof. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Implementer focused suites pass (transport **25**/tickets **12**, server **11**, boundary including certificate, lifecycle/policy/cookie-admin/store-serial/diff); independent reviewer reports no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. User explicitly authorized stacking the next bounded slice: credential/account, then other owner slices. Other mutations remain blocked; target/M2 acceptance and numeric reserves pending. This supersedes the first-slice next-step/continuation-pending statement below.
**First bounded increment history (2026-09-06):** [Implementation, restrictions and target checklist](phase8d7_implementation.md). Browser `reboot` and `web stop` now use existing deferred control with final WEB session/currentness checks and discard of pending input. Other identity/network/account/SSH restrictions remain explicit. Host suites/review/build pass: **95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** versus 8D.6, final build **12.44 s**. No new task/route/capacity. Target regression or explicit continuation decision pending; this is not completed 8D.7/M2. Next slice remains HTTPS identity/certificate handling, not settings. Prior sign-offs stand; numeric reserves remain open.
**Start in:** The console deferred-control boundary, web lifecycle owner, and only the affected command handlers.
**Scope:** Close the explicit 8D.5 restrictions for self-terminating web-shell operations (including HTTPS stop/identity changes and reboot where supported by the canonical registry). Reuse bounded deferred acknowledgement/close semantics; never claim application-buffer draining proves browser receipt. Prevent further input during pending actions. Preserve UART0-only bootstrap/recovery and safe policy for one-time self-generated credentials. No typed danger-zone API yet. If this requires several distinct owner changes, split them before implementation and keep unimplemented actions explicitly rejected.
**Gate — M2 (mandatory target/browser pause):** Browser/UART0/admin-SSH commands serialize with no output or hidden-prompt crossover; queued work is discarded after logout/revocation/slot reuse. Deferred acknowledgement/drain handling is bounded and reconnect behaves as documented; this is not confirmed peer receipt or a deadline for dispatcher queue residence or underlying certificate/NVS/lifecycle execution. Run simultaneous USB, serial WebSocket, admin WebSocket, user SSH, admin SSH, UART0 and UART1 traffic; verify lease retention, recovery availability, and measured memory/stack floors. Web-admin initialization/admission failure must leave M1 serial login and existing non-web paths usable. Stop for user confirmation before settings.
## M3 — Typed settings, one domain per chunk
Every row is a **separate implementation request**, not a batch. Add only the endpoints and UI needed for that row. All typed routes require current admin authorization, bounded bodies/responses, secret-safe encoding, and the established CSRF/origin/no-store policy. Do not send constructed command strings to `esp_console_run()`.
Typed operations must preserve subsystem owner/lock/persistence contracts and coexist safely with console operations. The current `admin_command_gate` is a narrow user-command wrapper, not an existing global typed-operation serializer; do not assume it solves concurrency. Specify per-domain serialization and committed-mutation notification before adding writes. Reuse a small common mechanism only when a concrete second caller needs it.
| Chunk | Bounded deliverable and starting source set | Focused acceptance gate / exclusions |
|---|---|---|
| **8D.8 — Read-only settings entry and Serial page** | Minimal admin Settings navigation, common bounded error handling, and a typed serial snapshot; start in `web_server`, `web_ui`, `serial_service.h`, `serial_config.h`. | Normal users are denied by server, unknown/oversized input fails safely, serial values match UART0. No mutations, schema generator, empty placeholder pages, or all-subsystem snapshot. |
| **8D.9 — Serial edits and persistence** | Typed framing/lifecycle and explicit apply/save/load/default/reset controls through existing serial APIs. | Invalid framing is rejected; working versus persisted state and stop/reconfigure data-discard effects are explicit. Writer ownership semantics stay unchanged; compare CLI/browser edits and reboot persistence. No quick popover yet. |
| **8D.10 — Accounts and passwords** | User list/create/delete/role/password workflows, final-admin protection, one-time generated-password handling; start in `user_database`, existing `user_console` mutation/revocation behavior, and web handlers/UI. | No raw database export, verifier fields, or UART0 recovery endpoints. Commit/invalidation behavior matches CLI, unrelated users remain connected, own-account changes have safe reconnect/credential-delivery semantics, secret fields are cleared after use. If CRUD and password UX exceed budget, split at read-only/role/delete versus create/password before editing. |
| **8D.11 — SSH authorized keys** | List fingerprints and add/delete/clear supported public keys through bounded user APIs. | Ed25519/P-256 import, maximum supported length, malformed input, duplicates, targeted revocation, and unchanged SSH authentication behavior. No private-key upload/export or host-identity management. |
| **8D.12 — Network settings without secret mutation** | Secret-free STA/AP/profile and mDNS settings, non-secret edits, and explicit persistence through `wifi_manager`, `wifi_config`, `mdns_service`/`mdns_config`. | Responses never serialize saved PSKs; validate working/persisted semantics, live hostname changes, and behavior after connection loss. No new manager/task or Wi-Fi blob migration. Split mDNS into a follow-up if needed. |
| **8D.13 — Wi-Fi secrets and connection controls** | Explicit password replacement/clear semantics, bounded transient input, profile selection/reconnect and AP policy actions using manager-owned operations. | Preserve existing secrets when fields are omitted; never prefill saved secrets; document apply/save and likely connection loss; reconnect via STA/AP and verify UART0/USB recovery. No background secret fetch or general credential export. |
| **8D.14 — Display settings** | Typed local display configuration and explicit persistence via `local_ui_config`/public UI APIs. | Validate limits, save/reboot, absent-display behavior, and concurrent buttons/CLI edits. No I2C ownership changes or electrical diagnostics UI. |
| **8D.15 — Bounded network diagnostics** | Secret-free network status and a narrowly bounded diagnostic workflow through existing network facilities. | Diagnostic start/result/cancel/expiry behavior and concurrent CLI use are bounded; callbacks do not format/send HTTP directly. No unbounded result/history buffer or new generic jobs framework. Split asynchronous ping from read-only status if necessary. |
| **8D.16 — Broker client visibility and writer transfer** | Admin-only detailed client snapshot plus explicit confirmed writer assignment using existing broker APIs; smallest broker change only if authoritative generation-safe validation is missing. | Stale/disconnected/reused target fails without changing the current lease; exactly one writer; normal users cannot obtain management details or transfer. Test concurrent USB/SSH/browser requests. No transfer on page open or selection alone. |
| **8D.17 — Serial/Wi-Fi quick popovers** | UI-only reuse of completed typed endpoints, with full-page links and shared validation; start in `web_ui`. | Hover, focus, click/tap parity, Escape/outside-click dismissal, no mutation on opening, explicit apply/save, no secret exposure. No duplicate backend or new settings scope. |
| **8D.18 — Client/writer contextual dialogs** | Reuse 8D.16 for live client popover and confirmed Active writer dialog. | Accessible pointer/keyboard/touch paths; refresh preserves explicit selection safely; stale confirmation is rejected visibly; normal users retain only ordinary status. No new writer policy. |
| **8D.19 — Ordinary service/session controls** | Typed service status and targeted disconnect/start/stop controls, excluding actions that cut off the invoking HTTPS session; start in existing service APIs and generation-safe snapshots. | Explicit scope/confirmation, stale target rejection, owner-safe execution and failure isolation. Stop/start and disconnect do not clear settings/identities. Split by service if more than one owner adaptation is necessary. |
| **8D.20 — Self-affecting service actions and reboot** | Confirmed typed operations for connection-losing HTTPS/Wi-Fi/service actions and reboot, reusing lifecycle behavior established in 8D.7/8D.13. | Acknowledgement/pending-action handling is bounded, no duplicate action on UI retry, connection loss is explained, recovery and reconnect work. No new unbounded queue or certificate/key rotation yet. |
| **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. |
Dependencies: 8D.8 establishes only the minimal typed-request/UI pattern. 8D.9 follows 8D.8; 8D.11 follows 8D.10; 8D.13 follows 8D.12; 8D.17 follows 8D.9/8D.13; 8D.18 follows 8D.16; 8D.20 follows 8D.7/8D.13/8D.19; 8D.21 follows 8D.20. Independent domains can be reordered by the user, but should not be implemented concurrently against shared `web_server`/`web_ui` files.
## 8D.22 — Final integration acceptance and documentation
No new feature work. Run the complete [Phase 8D acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration), including the accumulated per-chunk regression checks and a bounded concurrent-transport soak at supported workloads. Record the exact revision/configuration, browser(s), client mix, baud rate, test duration/cycle counts, linked RAM/flash, runtime free/minimum/largest internal heap and PSRAM, relevant stack margins, and queue/drop observations. Compare against 8D.0 and milestone measurements. Known high-load serial drops are not permission for new unbounded blocking, hidden lease loss, login failure, or resource exhaustion.
Verify optional web-session/admin/settings initialization failures preserve UART0 and native USB; admin-only failure must not disable otherwise working serial web access. Check logout/expiry/revocation across every added route, and verify no retained Basic path or direct typed endpoint bypasses policy. Review all routine snapshots/logs/DOM status for secret exposure and confirm changes did not regenerate unrelated vendor assets.
Update the roadmap and user/command documentation to distinguish completed features, explicit restrictions, build results, and tests actually passed. Update durable agent architecture/code-map notes only for implemented ownership/contracts. If a check fails, open a bounded repair chunk and rerun affected checks; do not append features or declare the whole phase done with unrecorded failures.
## Progress and next-request template
Progress: **8D.08D.6 and M1 validated by user sign-off; 8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07. Deferred parity restrictions remain; numeric reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; no new implementation is authorized by sign-off alone.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds.
Suggested next request:
> Wait for a separate request for 8D.8 read-only settings entry and Serial page. Preserve the accepted 8D.7/M2 sign-offs without requiring revalidation, deferred browser command restrictions, permanently UART0-only bootstrap/recovery and existing resource bounds. Carry forward the accepted nonblocking, unresolved web admission issue and unapproved numeric reserves/stack margins. M2 sign-off alone does not authorize settings or another owner slice.
For later chunks:
> Work on Phase 8D.N only. First verify its prerequisites and last target-validation checkpoint. State allowed files, exclusions, resource budget, and focused checks. Split the chunk if it does not fit one session with validation time reserved. Build, report actual versus pending validation and memory deltas, update the handoff, and stop; do not continue to the next chunk.
+185
View File
@@ -0,0 +1,185 @@
# Phase 9 bounded host validation
`tools/validate_phase9.py` is a thin, sequential orchestrator for existing test
runners, not a new security test implementation or a Phase 9 acceptance gate.
See [security hardening](security_hardening.md) for scope, historical evidence,
and the deferred whole-phase target checklist.
## Current candidate execution — 2026-09-18
Supplied final parent command, run from the repository root:
```sh
CCACHE_DISABLE=1 python3 -B tools/validate_phase9.py --build-dir .pio/idf-candidate-5.5.3/app-validated/.pio/build/esp32-s3-devkitc-1-n16r8 --idf-path .pio/idf-candidate-5.5.3/core/packages/framework-espidf --platformio-core-dir .pio/idf-candidate-5.5.3/core --interop --web-performance
```
**PASS 24/24.** This selects the actual fresh isolated build, SDK and toolchain core; it is not a default-root-build execution. The separate fresh `.pio/idf-candidate-5.5.3/app-validated` build **PASS** reports **95,552 B linked RAM / 1,749,493 B flash**, versus historical **94,340 / 1,768,901 B** (**+1,212 B RAM / 19,408 B flash**). The default parent `pio run` timed out after **200 seconds during installation, before compilation**; no normal root build PASS is claimed.
Pre/post source equality: **3,237 files**, SHA-256 **`3a1af78c15cfdd02da1055a8957b4086f9018862b7aa1c7c52fd2401a1a0a031`**. Actual generated-input registration covers **nine C sources plus one forced header**. WS receive tests exercise generated code: **982 cases / 10 mutation checks**, including the five signed `sizeof` corrections. The stale web-cookie fixture asserting IDF 5.5.0 was corrected, not bypassed. Historical stale-WS compilation/coverage failures in the [rebase review](idf_553_rebase_review.md) are resolved by this final snapshot, not hidden or retroactively called passes.
The [fix-bearing Wi-Fi bundle](wifi_security_update_plan.md) is integrated in the candidate, with unchanged PMF/WPA3; radio-hardware vulnerability closure and full target/resource/recovery gates remain pending. Nine notice catalog entries were semantically rebased, the other 66 unchanged (75 total); supplied notice evidence is **36 fixtures PASS**, two actual deterministic bundles each **77 files / 4,433,930 bytes**. Archive pins and source equality do not prove complete immutable root/ancillary/Python dependency closure, legal clearance or Phase 9 acceptance. This documentation update records supplied parent evidence; it did not rerun these builds, suites, bundles or hardware tests.
## Usage
From the repository root (the script itself also resolves the root independently
of the caller's working directory):
```sh
python3 -B tools/validate_phase9.py --dry-run
python3 -B tools/validate_phase9.py
python3 -B tools/validate_phase9.py --fail-fast
python3 -B tools/validate_phase9.py --timeout 300
```
Build and OpenSSH interoperability are **independent explicit opt-ins**:
```sh
python3 -B tools/validate_phase9.py --build --build-timeout 600
python3 -B tools/validate_phase9.py --interop --timeout 300
python3 -B tools/validate_phase9.py --build --interop --dry-run
```
These examples are usage, not execution claims. `--build` runs exactly `pio run`
first; it does not upload, erase, monitor, or select another PlatformIO environment.
PlatformIO itself may download dependencies or write its normal external caches.
The caller/parent must choose whether to build and arrange any required permission
separately. No installer, permission grant, network retry, or sandbox escalation
is implemented. A failed/timed-out build skips every dependent host command to
avoid testing stale artifacts as if that build succeeded.
`--interop` adds only `--interop` to `tests/wolfssh_order_contract/run.py` (its
default single matrix). That runner uses OpenSSH and local AF_UNIX sockets, not
an IP host or device. Socket permissions and installed OpenSSH tooling must
already be available; denial is a failure, not a reason to grant permissions.
Neither option is enabled by default. `--web-performance` independently adds the generated-WS performance runner (24 commands total). `--build-dir`, `--idf-path` and `--platformio-core-dir` select existing candidate artifacts/SDK/core; `--build` rejects a non-default build directory, which must be built separately. No target address, credentials, arbitrary
command, or device-operation argument is accepted.
## Coverage and prerequisites
The fixed default plan contains **23 commands**; `--dry-run` prints their exact
argv vectors and timeouts without launching anything or checking prerequisites.
There is no discovery-time execution of runners (some run at import/top level).
Paths and options were inspected in their actual `run.py` implementations.
| Scope | Existing runners / modes |
|---|---|
| 9A effective crash policy | `security_build_policy --sdkconfig-header .pio/build/esp32-s3-devkitc-1-n16r8/config/sdkconfig.h` |
| 9B admission / credentials | `ssh_auth_policy`, `ssh_auth_transport`, `hidden_input` |
| 9C allocator | `ssh_memory` |
| Current seven-suite baseline | `sdk_security_overrides --build-dir .pio/build/esp32-s3-devkitc-1-n16r8`, `wolfssh_auth_contract`, `ssh_protocol_policy`, `wolf_crypto_policy`, `wolfssh_parser_contract`, `wolfssh_order_contract`, `release_notices` |
| Focused SSH/admin and HTTPS lifecycle regressions | `ssh_management`, `admin_console_boundary`, `admin_ssh_policy`, `web_admin_transport`, `web_admin_tickets`, `web_httpd_idle` |
| Web early admission, credentials and administrative lifecycle | `web_cookie_auth` default plus separate `--admin`, `--accounts`, `--ssh`, `--lifecycle` commands |
Every runner path is `tests/<name>/run.py`. Auth contract, protocol policy, and
crypto policy receive explicit `--compile-commands
.pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json` arguments. No
`--host-only`, candidate injection, or silent fallback is used. Default operation
requires an existing configured build, installed pinned SDK/managed sources,
the production toolchain and host compiler/libraries needed by these runners
(including OpenSSL development support for relevant tests). It **does not run
PlatformIO** to satisfy those requirements. Missing explicit files/executables
are `PREREQ` failures; deeper source/toolchain prerequisites are checked by the
existing runners and surface as nonzero `FAIL` results. Existing artifacts are
not proof that the current checkout has been freshly built.
The current cookie-auth runner has no `--admission` branch: the default exercises
early admission/wiping, and each selected domain mode repeats that baseline.
Unrelated display/network/UI suites are intentionally excluded; web performance is an explicit opt-in.
`ssh_memory` retains its documented optional installed-IDF extent-source check
as skipped when no `--idf-path` is supplied; explicit candidate selection enables it. Its normal allocator contracts still run.
The ordering runner's candidate `--target-contracts`, separate `--pio-adapter`
regression, and repeated interop matrices are not added implicitly. Run those
separately when their particular evidence is needed. This is a focused full
Phase 9 host regression set, not every test in the repository.
## Bounds, output, and result semantics
- Each host command has a 180-second wall-clock timeout by default; the optional
build gets 600 seconds. Both timeout flags accept finite values in `(0, 3600]`.
The fixed sequential plan bounds total child execution by the sum of its
timeouts (default 4,140 seconds), plus launch/reporting/cleanup overhead.
- POSIX process groups are required. Timeout or Ctrl-C kills the active process
group and reaps the runner; successful/failed exit also retires any remaining
group descendants. This covers normal compiler/test children, not deliberately
detached processes. Group reaping has a five-second wait bound.
- Commands use argv lists, no shell evaluation or interpolation, with stdin
closed. The child environment is copied with `CCACHE_DISABLE=1`; explicit `--idf-path` and `--platformio-core-dir` also set resolved `IDF_PATH` and `PLATFORMIO_CORE_DIR` for children;
arbitrary compiler, SDK, proxy and other caller environment settings are not
overwritten. The parent's environment is not mutated.
- stdout/stderr are inherited live. The orchestrator creates no persistent log,
JSON capture, environment dump, or secret artifact. Existing runners retain
their own temporary fixture/build behavior; forced termination can bypass
their temporary-directory cleanup. Console output is **not a redaction
guarantee**: use synthetic fixtures, review before sharing, and do not redirect
output into routine secret-bearing persistent captures. Build outputs remain
normal PlatformIO artifacts only when explicitly requested.
- `PASS` means the entire runner exited zero, not that an earlier printed PASS
line appeared. The final summary records each command's exit, timeout or
missing prerequisite. Defaults collect failures and continue independent
commands; `--fail-fast` reports remaining commands as `SKIP`. Build failure
always stops dependent commands. No selected suite is silently dropped.
- Exit 0 means all selected commands passed, or a dry-run plan was printed.
Dry-run uses `PLAN`, never `PASS`, and is not validation. Failures, timeouts,
prerequisites and dependent skips return 1; invalid CLI arguments return 2;
Ctrl-C returns 130. Optional build/interop omissions are announced separately
and do not turn a successful host run into failure.
## Orchestrator fixture tests
```sh
python3 -B tests/phase9_validation/run.py
```
Offline unittest groups check the exact plan/paths/strict arguments, independent
opt-ins, timeout configuration and invalid values, dry-run nonexecution, default
build/interop omission, failure collection, fail-fast and failed-build skips,
missing file/executable handling, literal argv/environment preservation,
timeout/descendant termination, and inherited streams/closed stdin. They execute
only small Python fixtures, never PlatformIO, interop, devices, or network.
## Execution record — 2026-09-16
Initial orchestrator-agent execution, under the existing sandbox without elevation (historical first run):
- `python3 -B tests/phase9_validation/run.py`: **10 tests PASS**.
- `python3 -B tools/validate_phase9.py --dry-run`: exit 0; **plan only**, no
prerequisite or suite validation.
- `python3 -B tools/validate_phase9.py`: **exit 1, 22 PASS / 1 FAIL**, no timeouts;
about 57 seconds of summed command time. Build and interop explicitly skipped.
- Direct rerun `CCACHE_DISABLE=1 python3 -B tests/admin_console_boundary/run.py`:
reproduced exit 1 at line 40, `ValueError: substring not found` for
`ssh.index("static void *ssh_malloc(")`. Earlier subtest PASS lines do not make
the runner pass. The existing runner is outside this change's ownership and
was not edited.
Passed commands: `security_build_policy`, `ssh_auth_policy`, `ssh_auth_transport`,
`hidden_input`, `ssh_memory`, **all seven current baseline suites listed above**,
`ssh_management`, `admin_ssh_policy`, `web_admin_transport`, `web_admin_tickets`,
`web_httpd_idle`, and all five selected `web_cookie_auth` modes.
Only `admin_console_boundary` failed.
No firmware build, interop, device, runtime reserve, hardware panic/recovery,
release delivery, or whole-Phase-9 acceptance result is claimed for this task.
### Follow-up supplied evidence
The owning agent corrected the stale `admin_console_boundary` source anchor;
its subsequent default orchestrator run reports **23/23 PASS**. This supersedes
the initial 22/23 host result above, without turning its early PASS lines into
a successful initial run. Final supplied parent execution now confirms:
- `CCACHE_DISABLE=1 python3 -B tools/validate_phase9.py`: **PASS 23/23**.
- `tests/phase9_validation/run.py`: **PASS 10 tests**.
No build or interop ran this turn; both remain separate opt-ins, not default
execution. Firmware source is unchanged. These are actual parent results supplied
for this record, not another execution by this documentation integration.
The supplied notice agent also reports **36 fixture tests PASS** and two actual
75-input bundles, each **77 files / 4,433,930 bytes**, verified deterministic; see
[packaging evidence](release_packaging.md). These are agent measurements, not a
new build or bundle measurement by this documentation integration.
The [finite advisory search](dependency_advisory_coverage.md) is complete for its
declared snapshot, but the [PMF-capable SoftAP blocker](wifi_security_update_plan.md)
was **not fixed at that historical snapshot**. The current fix-bearing bundle is now integrated in the validated candidate; host success does not validate radio behavior, clear distribution obligations or complete Phase 9.
+249
View File
@@ -0,0 +1,249 @@
# Offline release notice packaging
This procedure produces a **bounded notice/provenance bundle**, not a firmware
release, corresponding-source archive, complete SBOM, or legal clearance.
The **radio-blob corresponding-source/System Library question remains open**.
See [the license inventory](dependency_licenses.md) for the original review and
[the retained scope/provenance statement](../third_party/release-notices/README.md)
for license choices, mixed notices, wolfSSH packaging discrepancy, TLSF text
assembly, icon limits, and remaining release gates.
## Run against the installed reviewed snapshot
Requirements: Python 3.9+ on Linux/POSIX with `O_NOFOLLOW`, directory descriptors
and `dir_fd` operations; already installed matching managed components,
ESP-IDF and Xtensa toolchain. Standard library only. The tool never fetches,
installs, builds, runs PlatformIO, accesses a device, or regenerates assets.
Run from the repository root. Supply **an explicit, not-yet-existing output
directory outside all three input roots**, with an existing parent. Example
for the reviewed installed 5.5.3 candidate (choose a fresh final directory name):
```sh
python3 tools/release_notices.py \
--sdk-root /home/mscholz/Repos/ESP32_serial_swiss_army_knife/.pio/idf-candidate-5.5.3/core/packages/framework-espidf \
--toolchain-root /home/mscholz/Repos/ESP32_serial_swiss_army_knife/.pio/idf-candidate-5.5.3/core/packages/toolchain-xtensa-esp-elf \
--output /tmp/esp32-release-notices-review-01
```
`--project-root` optionally selects a different checkout containing the exact
reviewed inputs; by default it is the script's repository. The trusted catalog
always comes from the script's repository, not from that alternate checkout.
Paths to SDK/toolchain packages are explicit; no installation is guessed.
There is no user catalog option, force/overwrite mode, automatic repinning,
recursive collection, archive command, or automatic release upload.
Every input in `third_party/release-notices/inputs.json` is mandatory and pinned
by **full-file SHA-256 and size**. Missing sources, altered bytes, bad excerpts,
empty files, directories, FIFOs, symlinks (even to identical files), parent
traversal, binary notice payloads and exhausted bounds fail closed. Symlinks in
input-root or output-parent ancestry are rejected too: pass real non-symlinked
paths, not a convenience symlink. All input preflight completes before creating
output. The limits are 128 inputs, 4 MiB per input/catalog and 32 MiB cumulative
source bytes. The current catalog has **75 inputs**, including the retained SVGs and pinned
[icon provenance evidence](icon_provenance.md). Overview documents edited in this
integration are not catalog inputs; `docs/icon_provenance.md` **is pinned**.
Coordinate any change to that record with the catalog owner; never silently repin.
An existing output file, directory (even empty) or dangling symlink is never
overwritten. Parent directories are not automatically created. Output uses
private directory/file modes (0700/0600, subject to umask). On a write failure,
the tool deliberately leaves any partial directory for inspection and never
recursively deletes it. A successful exit plus a valid manifest and matching
payload hashes is required; presence of a partial manifest is not success.
Use another fresh path for a retry. Do not put output into a tree that an
untrusted process can concurrently rename or modify; descriptor/no-follow
checks prevent symlink traversal but are not isolation from the same OS user.
## Bundle layout and reproducibility
- `README.txt`: limitations and entry point for recipients.
- `inputs/project/…`: project GPL text, resolved lock, managed licenses and
manifests, wolfSSL/wolfSSH README evidence, original wolfSSH header excerpts,
xterm MIT texts and recorded provenance, icon summary and official 7.4.47
SVG/license/metadata evidence, TLSF full rendering
and scope/provenance statement.
- `inputs/sdk/…`: complete scoped SDK license documents, mixed Newlib/argtable3
notices, radio license files, package/version identity and original source
notice excerpts. SDK `LICENSE` supplies full Apache-2.0 text; it does **not**
authenticate individual icons or cover all SDK files.
- `inputs/toolchain/…`: actual toolchain Newlib text, GPLv3, GCC Runtime Library
Exception 3.1 and package identity. No toolchain executable is copied.
- `manifest.json`: schema/snapshot, catalog SHA-256, logical root/path,
full-source size/hash, optional zero-based half-open byte range, purpose,
output path/size/hash, and the generated introduction's hash. Excerpts have
`.notice.txt` appended; null ranges retain whole original files unchanged.
Whole mixed documents are preserved, not converted into guessed SPDX labels.
The longer toolchain Newlib notice does not replace the distinct SDK text.
The wolfSSH package GPLv2-or-later text and GPLv3-or-later source headers remain
separate and unmodified; upstream packaging clarification is still needed.
Generated metadata contains no collection timestamps or absolute host paths;
original notices retain their own dates and text. For identical catalog and
inputs, file names and file contents are deterministic across
output locations, input-root locations and source mtimes. Directory/file
mtimes are **not** normalized, and no reproducible tar/zip archive is claimed.
The manifest hashes all payloads but not itself; retain its hash in your
separate release evidence if needed. The catalog is trusted reviewed policy,
not a cryptographic signature or proof of package-wide integrity/authorship.
Only explicit catalog paths are read. No application config, sdkconfig,
build tree, ELF/map/binary, key, credential backup, NVS/flash dump or whole
repository is collected. Hash-pinned source implementations are read only to
validate original header excerpts, not copied as implementations. The lock and
installed package/component manifests are dependency identity metadata, not
build configuration. A hash pin is not a general-purpose secret scanner:
review additions to this trusted list before accepting them.
## Validate and inspect
Temporary-fixture regression suite (does not need installed packages):
```sh
python3 tests/release_notices/run.py
```
The suite tests missing/changed sources (including changes outside a notice
excerpt), exact mixed-text retention, byte ranges, path traversal, source/root/
output symlinks, existing user data, nonregular files, bounds, output failures,
secret/config exclusion, determinism, and CLI behavior. No hardware tests are
implied.
For a real bundle, check the successful process exit, parse `manifest.json`,
recompute every payload hash/size, check no unlisted files are present, and
read the scope statement. Compare two independently created fresh outputs by
relative names and bytes (not mtimes). Inspect at least both Newlib files,
argtable3, wpa_supplicant COPYING/README, GCC exception, wolfSSH LICENSE/README
and header excerpts, TLSF text/provenance, both xterm notices and the icon
summary. These checks establish scoped assembly, **not release compliance**.
If a pinned input differs, stop and review the changed package/license/source.
There is deliberately no update-catalog command. Review versions, new/nested
NOTICEs, original source notice ranges and hashes, then deliberately update
catalog policy and test it. The runtime does not discover added notices or
packages outside its finite list; this limitation makes its reads bounded and
prevents opportunistic collection of user files.
## Supplied execution evidence — 2026-09-16
Agent results: **36 notice fixture tests PASS**, and **two actual 75-input
bundles verified deterministic**, each **77 files / 4,433,930 bytes**. These
replace the earlier 62-input / 64-file / 541,147-byte snapshot, not its historical
record. No parent rebuild or bundle reassembly was run in this documentation
turn; firmware source is unchanged. Assembly is not legal clearance or Phase 9
acceptance. The [unfixed Wi-Fi blocker](wifi_security_update_plan.md) also remains
a separate security gate; a future coherent vendor update requires catalog review.
## Installed candidate notice rebase — 2026-09-18
This addendum audits the existing 75-input catalog against **already installed**
PlatformIO 6.13.0 migration candidates, not merely downloaded archives. No
installation, build, production/config edit, or security-override rebase was
performed by this notice audit. The preceding 2026-09-16 evidence is historical.
Actual candidate package root:
`/home/mscholz/Repos/ESP32_serial_swiss_army_knife/.pio/idf-candidate-5.5.3/core/packages/`.
Its `framework-espidf/package.json` reports **3.50503.0**, and `version.txt`
reports **5.5.3**. Both `toolchain-xtensa-esp-elf/package.json` and
`toolchain-riscv32-esp/package.json` report **14.2.0+20251107**. RISC-V identity
was checked for context; it is not a new input root or a claim of RISC-V notice
coverage. The shared `/home/mscholz/.platformio/packages/` still contains
framework **3.50500.0 / IDF 5.5.0** and both toolchains **14.2.0+20241119**;
do not use those shared paths with this rebased catalog.
All 75 old catalog full-file hashes/sizes matched the old shared SDK/toolchain
and current project inputs before editing. Comparison against the installed
candidate found exactly **nine changed entries** (eight SDK, one Xtensa),
listed below. The other **66 entries remain unchanged**, including all **39
project/managed inputs**, the dependency-lock pin, icon evidence and managed
version map. No catalog paths, purposes unrelated to version identity, or
excerpt ranges were added/removed/relaxed. Snapshot identity/date and the three
identity-purpose strings were updated deliberately, not by automatic repinning.
Paths below are relative to the indicated catalog root. Hashes are full-source
SHA-256; old hashes remain in the preceding Git catalog revision.
| Root / path | Old → candidate bytes | Candidate SHA-256 |
| --- | ---: | --- |
| sdk / `components/esp-tls/esp_tls_mbedtls.c` | 50176 → 54323 | `edc39052244526cb91c93a16bc765194031060e5560fd432e75542f2f6c8db53` |
| sdk / `components/esp_http_server/src/httpd_parse.c` | 42937 → 42937 | `db7fbbb322bccb4a21bc1607208a6bb7d1f4c0d7ecc3299de10c9e60edaa943b` |
| sdk / `components/esp_https_server/src/https_server.c` | 15257 → 15459 | `a2a5ca0549fbe8d1ddd7f9647a48a31fdd6329b997e1550ab69d6a10efecff0d` |
| sdk / `components/freertos/FreeRTOS-Kernel/include/freertos/task.h` | 133072 → 133108 | `8b908b964d2c87377838762478253eec04312b985e6bb680bacd3f03ab253897` |
| sdk / `components/mbedtls/mbedtls/library/ssl_tls.c` | 329921 → 336963 | `0154e70f20b465213d3f97a9c2b75e686d79ee83592f90e1d7e7d995593ca7e1` |
| sdk / `components/mbedtls/mbedtls/library/x509_create.c` | 19803 → 19934 | `55edce5b8fcb039a404b84ea830f06d018128b155dfbf0d74d26b17247b546c6` |
| sdk / `package.json` | 536 → 536 | `230c52993499adf9f64d1d6477495ea46c3f14d63022bd52081858373098da68` |
| sdk / `version.txt` | 5 → 5 | `b473c8e2ed91b38d7e96e54d4ce094474ca6015c0641b7cdfc0ab94b1f8b4668` |
| toolchain / `package.json` | 472 → 472 | `bdd3b852d051d6513912e71d29851bd04b800e2d33d78aec48067af5d83da5c9` |
Semantic notice review: five changed implementation files retain byte-identical
complete opening notices at their existing ranges (ESP-TLS `[0,122)`, HTTPD
`[0,124)`, HTTPS `[0,123)`, Mbed TLS shared TLS `[0,223)` and X.509 `[0,169)`).
Their output hashes stay unchanged; full-source hashes must change even when
only implementation bytes change. This does **not** approve the implementation
changes or establish compatibility of the project's security overlays.
FreeRTOS `task.h` retains its complete MIT notice at `[0,1455)`; only
`SPDX-FileContributor: 2023-2024` becomes `2023-2025` within that excerpt.
Its output hash changes from
`8e3d9f79bbbdf6dfd6fbf132982a7ec3a45ff6dac92277034316efae4cccad84` to
`e53c9bcefd4327c713696e870a3568a27cedf8833467b9cd9536cb2e09ab7ad8`.
The remaining `task.h` changes correct stack-watermark documentation to bytes.
The two package JSON diffs change only versions; SDK `version.txt` changes only
`5.5.0` to `5.5.3`.
All 24 remaining SDK inputs and three Xtensa license inputs match the old pins,
including SDK/radio Apache texts, both Newlib documents, mixed argtable3,
wpa_supplicant and GCC exception/GPL texts. An auxiliary installed-tree filename
comparison found the same 45 SDK files whose basenames start with LICENSE,
COPYING or NOTICE (case-insensitive), with no additions/removals. All 85 files
under Xtensa `share/licenses/` have identical relative paths and hashes. This
finite discovery check is not an exhaustive attribution or linked-object audit.
Validation after the catalog edit: **36/36 notice fixture tests PASS** via
`python3 -B tests/release_notices/run.py`; two fresh temporary actual bundles
using the candidate roots above and the current project passed assembly,
per-payload manifest SHA-256/size checks, exact file-list checks, and identical
relative names/bytes across outputs. Each has **77 files / 4,433,930 bytes**
(the unchanged total size does not imply unchanged contents). Both manifest
SHA-256 values are
`1fd0a69b82dc2632bab4ab6dda33675e7229c8d341c7fafb7158c1e8f2456a68`.
Temporary bundles were removed after comparison. Project entries and managed
snapshot pins were also asserted equal to the pre-migration Git catalog.
These results are **notice assembly evidence only**, not legal clearance,
corresponding-source delivery, radio-blob exception approval, vulnerability
closure, firmware build validation or target acceptance. Existing pinned project
provenance retains historical baseline wording; this addendum distinguishes the
new SDK notice snapshot without silently rewriting/re-pinning that evidence.
All separate distribution gates below remain open.
## Separate work before distribution
1. Resolve or obtain qualified review of radio-blob corresponding-source or
exception basis for the actual linked firmware. Apache binary permissions
alone do not settle this question. No legal clearance is claimed.
2. Resolve the wolfSSH package/header discrepancy and remaining icon derivative
questions. [Exact retained SVG provenance](icon_provenance.md) is verified at
official 7.4.47, commit `9e04201d4557e729822fb57f62a316c3dea1d4a8`: Google
USB and Simran Wi-Fi. Full Apache text is retained; no NOTICE was found in
that pinned distribution. Manual firmware bitmap derivation and the distinct
mockup Wi-Fi path remain unresolved. No artwork was regenerated.
3. Audit the final application/bootloader/runtime object set and aggregation
boundaries. Full SDK/toolchain redistribution needs broader nested/tool/test
notice coverage; this is not an all-files license scanner.
4. Prepare and validate a separate, deliberately reviewed corresponding-source
delivery. Preserve exact required sources and safe build/install inputs,
asset preferred sources and scripts, original hash-matching overlay inputs,
`tools/security_overrides.py`, `cmake/security_overrides.cmake`, root CMake
integration, `cmake/wolf_crypto_policy.{cmake,h}`, and the complete
`tools/wolfssh_order/` inputs/provenance/archives. Preserve upstream notices
and generated dated change notices on eight C sources and one PUBLIC forced
header. A stock vendor tarball or this notice bundle is not sufficient.
Do not automatically archive the working repository or real configurations.
5. Assess GPLv3 source delivery/offer and User Product Installation Information.
Protect device passwords, Wi-Fi credentials, private keys, verifiers,
tickets, credential backups and flash/NVS images throughout that process.
6. Arrange and verify actual notice delivery with firmware, devices and browser
assets. This command does not add a license endpoint, change browser assets,
or prove that a recipient of a JS response or standalone device receives
notices. Keep these gates open until the real delivery path is validated.
+42 -46
View File
@@ -38,8 +38,8 @@ These constraints apply across all phases:
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
| 6 | Authenticated SSH serial transport | **Complete** |
| 7 | Local display and button interface | **Complete** |
| 8 | Role-based users and administrative access | **In progress (8A8C complete; integrated web administration 8D planned)** |
| 9 | Security and production hardening | **Planned** |
| 8 | Role-based users and administrative access | **Complete (8D.22 accepted 2026-09-13)** |
| 9 | Security and production hardening | **In progress (9A9C implemented; combined phase validation deferred)** |
| 10 | Authenticated, rollback-capable OTA | **Planned** |
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
| 12 | Advanced network integration | **Under evaluation** |
@@ -169,70 +169,66 @@ Implemented and target-hardware validated:
- Missing, disconnected, or unresponsive display hardware remains nonfatal and can recover through one bounded reprobe.
- Concurrent serial, UART0, USB, WebSocket, SSH, and injected display/button fault behavior validated. See [Electrical tests](electrical_tests.md) and [Hardware wiring](wiring.md).
## Current and planned phases
### Phase 8 — Role-based users and administrative access — Complete
Phase 8A through 8C are complete and target-hardware validated; integrated web administration in 8D remains planned. Later work remains planned or under evaluation. Optional features must not weaken the completed serial and recovery paths.
Implemented and accepted: a bounded, persistent user system replaces the single shared network credential. Both roles can use the authenticated web serial/status interface. Over SSH, `user` routes to the broker-backed serial stream while `admin` routes exclusively to the administration shell and receives no broker client or writer lease. UART0 remains the physical recovery and bootstrap authority.
### Phase 8 — Role-based users and administrative access
Replace the single shared network credential with a bounded, persistent user system. Both roles can use the authenticated web serial/status interface. Over SSH, `user` routes to the broker-backed serial stream while `admin` routes exclusively to the administration shell and receives no broker client or writer lease. UART0 remains the physical recovery and bootstrap authority.
Implementation sequence:
Completed implementation (8A8C target-hardware validated; 8D explicitly accepted at 8D.22 on 2026-09-13):
1. **Phase 8A — User database and UART0 administration — Complete**
- Versioned NVS database for up to eight `user`/`admin` accounts, random account IDs and authentication generations, final-admin protection, and three Ed25519/P-256 keys per account.
- Salted PBKDF2-HMAC-SHA256 password verifiers, bounded no-echo entry, one-time generated passwords, and secret-free account/key status.
- Transactional migration of the legacy credential to an unprivileged account, explicit UART0 bootstrap, and UART0-only recovery that replaces only the unavailable user blob.
- Historical migration/bootstrap supported the initial cutover. Current firmware instead commits missing storage empty, provisions the first admin with normal UART0 `user add`, and provides UART0-only unavailable-database recovery; legacy credential/bootstrap commands are removed.
- Migration, bootstrap, CRUD, persistence, and command basics target-hardware validated; the full fault-injection matrix remains regression coverage. NVS remains unencrypted and offline guessing remains possible.
2. **Phase 8B — Role-aware HTTPS and SSH authentication — Complete**
- HTTPS Basic plus SSH password/public-key authentication use the common database and copied secret-free principals; unavailable user storage fails closed.
- HTTPS and SSH authenticate through the common database and copied secret-free principals; unavailable user storage fails closed. The initial HTTPS Basic path was replaced by cookie authentication in 8D.
- One-time principal-bound WebSocket tickets and ongoing principal-currentness checks prevent stale admission or input.
- Account mutations request targeted WebSocket/SSH revocation; authentication generations provide fail-safe invalidation while unrelated accounts remain connected.
- Legacy credentials remain migration/recovery-only after bootstrap. Password/key login, ticket behavior, targeted revocation, recovery, and concurrent transport operation are target-hardware validated.
- Password/key login, ticket behavior, targeted revocation, recovery, and concurrent transport operation are target-hardware validated. Legacy credentials were subsequently removed without changing established TLS identity or valid users.
3. **Phase 8C — SSH administrative shell — Complete**
- Authenticated `admin` SSH shell sessions route to a bounded administration worker and never create a broker client or acquire a serial writer lease. Normal `user` sessions retain the existing broker-backed serial stream.
- UART0 and admin SSH now submit complete lines to one fixed-length request queue. A single dispatcher task is the sole caller of ESP-IDF's non-reentrant `esp_console_run()` and therefore executes the same registered command handlers for both entry routes. The former separately implemented reduced SSH command dispatcher has been removed.
- The worker uses fixed per-session command/input and output buffers. Queue records contain copied secret-free principals and generation-tagged session tokens; late work is discarded after disconnect, slot reuse, role change, password/key mutation, or deletion. Task-local standard streams route canonical handler output into the applicable bounded SSH ring, and only the SSH owner task calls wolfSSH APIs.
- Transport-neutral bounded prompts now support interactive user passwords/keys and Wi-Fi secrets over admin SSH without exposing hidden input or allowing another command while a prompt is active. Ping callbacks enqueue typed bounded events and the dispatcher alone formats their output. Four-entry per-session history and whole-line Tab completion are RAM-only and wiped on disconnect.
- Authenticated administrators receive the operational registry, including recovery-secret display, HTTPS material rotation/reset, reboot, ping, and SSH lifecycle/session/host-key mutation. Self-terminating reboot and SSH actions are deferred until acknowledgement output drains, block further shell input, and execute through existing synchronous owner APIs from a separate bounded control task. Initial `user bootstrap` and explicit `user recover --force` remain physical-UART0 operations; admin SSH also rejects generating a replacement password for its own account.
- Authenticated administrators receive the operational registry, including HTTPS material rotation/reset, reboot, ping, and SSH lifecycle/session/host-key mutation. Self-terminating reboot and SSH actions are deferred until acknowledgement output drains, block further shell input, and execute through existing synchronous owner APIs from a separate bounded control task. First-admin provisioning and explicit `user recover --force` remain physical-UART0 operations (`user bootstrap` was later removed); admin SSH also rejects generating a replacement password for its own account.
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort application-buffer draining. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled.
- Target-hardware validation passed for route separation, history/Tab editing, interactive visible/hidden prompts, output/backpressure, generated and entered user/password/key management including the longest ECDSA P-256 import, ping event routing, deferred reboot/SSH lifecycle drain behavior, bootstrap/recovery rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation. Stress at 460800 baud with SSH and WebSocket clients caused substantial expected packet drops and slower display controls, but did not exhaust memory or require lowering the supported baud-rate range.
4. **Phase 8D — Integrated web administration — Planned, staged delivery**
- **Implementation checkpoint:** 8D.08D.6 and M1 validated by user sign-off; [8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07](phase8d7_implementation.md) ("Jupp, sign M2 off"). This supersedes earlier M2-open statements without requiring revalidation. Browser selector, bounded stop/reboot, certificate rotation and other-account operations are implemented. User reports verified certificate rotation/web start-stop and full mix without broker drops up to 230400 baud after correcting external adapter baud. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Full parity is not claimed; numeric memory reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. The requirements below retain the full end-state scope, not additional conditions reopening accepted M2.
- **Execution plan:** [Phase 8D incremental plan](phase8d_plan.md). Implement one numbered chunk per request, with a build, focused regression checks, memory accounting, and a handoff before stopping. The requirements below describe the final scope, not one implementation task.
- **Milestones:** 8D.08D.3 establish a measured baseline and reliable login/logout with the existing serial UI; 8D.48D.7 add the shared browser admin shell and verify retained serial ownership; 8D.88D.21 add typed settings and contextual controls one domain at a time; 8D.22 performs final integration acceptance. Login and runtime-memory target validation gate the first two milestones; do not defer them until the entire phase is implemented. No wholesale import of the rolled-back experimental implementation.
- Begin with integrated authentication: replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
- Make logout invalidate the current server-side session, expire its cookie, close that session's serial and administrative WebSockets, and redirect to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
- Add an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
- Switching the visible terminal between Serial and Admin changes only the displayed terminal route. It must not disconnect the browser's serial broker client, release its writer lease, or silently stop serial observation. Keep the writer/observer badge plus Request control/Release control visible in both modes so an administrator knows the retained state and cannot unintentionally lose the lease to another client. Explicit Disconnect, logout, revocation, session expiry, or an explicit release/transfer operation still performs normal broker cleanup.
- Add an admin-only Settings area backed by typed, bounded subsystem APIs rather than generated CLI strings. Cover user/password/role/SSH-key management, serial configuration and persistence, Wi-Fi profiles/AP policy/secrets, service and session controls, display settings, network diagnostics, and carefully separated security/danger-zone operations. The admin shell provides full remote operational parity and the settings pages provide guided high-frequency workflows; unusual electrical/debug operations may remain shell-only.
- Add contextual admin quick settings to the existing status cards. Hover, keyboard focus, or click on **Serial** opens a popover with current framing/lifecycle state, safe common edits, apply/save semantics, and a link to full Serial settings. The **Wi-Fi** card similarly exposes connection/profile controls and a link to full Network settings without revealing saved secrets by default. Touch and keyboard users must receive the same functionality as pointer hover, with Escape/outside-click dismissal and no action triggered merely by opening a popover.
- Hover, focus, or click on **Broker clients** opens a live, secret-free client list with IDs, transport, writer/observer state, and bounded queue/drop information. Activating **Active writer** opens an admin-only transfer dialog listing current eligible clients; transfer is an explicit confirmed generation-safe administrative assignment, never a side effect of opening or hovering. Stale/disconnected targets fail visibly without changing the current lease. Normal users may retain ordinary aggregate status but receive neither client-management details nor mutation controls.
- Preserve strict CSP, no-referrer/frame-denial policy, no-store responses for login/session/admin material, secret-safe JSON encoding, one-time generated-password display, and bounded request/response bodies. Keep UART0 recovery, native USB UART1 access, and existing SSH behavior available if web sessions or administration cannot initialize.
- Add secret-free counters/snapshots for session capacity, expiry, login failure/backoff, logout, invalidation, CSRF/origin rejection, admin-console admission/backpressure, typed-API failure, and writer-transfer races. Compatibility HTTP Basic, if retained temporarily during migration, must be explicit, separately constrained, and unable to bypass logout or revocation.
4. **Phase 8D — Integrated web administration — Complete**
- User explicitly signed off tested firmware at **8D.22 on 2026-09-13**, superseding earlier per-slice pending acceptance/review gates. Cookie login/logout replaces Basic; bounded digest-only sessions, same-origin/CSRF checks, principal currentness and targeted revocation protect serial and admin routes.
- Admin-only Serial/Admin selection and Settings preserve connected terminal observation and serial writer ownership. The browser shell shares the serialized UART0/admin-SSH dispatcher, with bounded output, explicit admission and retained frontend restrictions—not unrestricted shell parity.
- Typed Serial, Accounts/password/authorized-key, Network/Wi-Fi/mDNS, Display, Broker and SSH settings; confirmed HTTPS stop/restart/reboot and HTTPS/SSH identity rotation. Canonical owners compare reserved service/identity/target generations, preserve commit/failure semantics and expose no stored secrets. Admitted work is not cancelled by later logout/timeout; uncertain mutations are never automatically replayed.
- Accessible Serial/Wi-Fi/client/writer contextual controls reuse existing controllers. Writer transfer is explicit and generation-safe; one writer, isolated observers, binary transparency, UART0 administrative recovery and network-independent native USB UART1 remain invariants.
- Scope removed by user: **8D.15** dedicated typed network diagnostics (shell diagnostics retained) and the unimplemented **8D.19** ordinary browser-session/native-USB controls (SSH controls retained). Browser identity reset/recovery/export is excluded; canonical recovery remains available.
- Prior final build PASS: **100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. Earlier combined binary WebSocket-send fix was explicitly accepted at **160 MHz / 230400 baud with full client mix including browser admin**. Latest overall sign-off does not invent individual fault/soak/duration results or a latest exact zero-drop comparison.
- Latest loaded internal/DMA minima **2,052 / 460 B** remain a conservative transient-headroom follow-up, not a blocker reopening acceptance or an approved reserve. See [acceptance and telemetry](web_administration_acceptance.md), [current contracts](web_administration.md), [regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and [legacy compatibility](legacy_credential_removal.md).
Completion requires login/logout and account switching without browser credential-cache dependence; stale-session, CSRF, origin, capacity, expiry, and revocation tests; hidden and server-rejected normal-user administration; shared admin-console serialization and backpressure; terminal switching that demonstrably preserves the browser broker client and writer lease; accessible Serial/Wi-Fi/client/writer popovers; generation-safe explicit writer transfer; typed settings and secret-handling tests; concurrent USB/WebSocket/user-SSH/admin-SSH/browser-admin operation; and continued UART0 recovery.
## Current and planned phases
Phase 8 is complete for its accepted scope. Phase 9 has started at the user's request; later work remains planned or under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below remain guidance for future work, not claims that every listed fault, soak or reserve measurement was individually performed for 8D.
### Phase 9 — Security and production hardening
Harden stored credentials, boot integrity, update trust, failure diagnostics, and dependency maintenance. This phase must be staged carefully because some ESP32 eFuse operations are irreversible.
**In progress.** Harden network authentication, secret lifetimes, crash/debug exposure and operational maintenance. Secure boot and encrypted NVS are explicitly excluded by user preference. No eFuse, partition, at-rest encryption or dependency-version upgrades are part of 9A9C; 9C adds source-pinned build-tree dependency corrections; no future flash/PSRAM encryption commitment is implied. Physical extraction and firmware replacement remain outside the threat model after Phase 9, and software debug restrictions do not imply physical JTAG fuse restrictions.
Planned work:
Staged work:
- Define development, test, and production security profiles.
- Enable and validate NVS encryption for Wi-Fi, HTTPS, and SSH material.
- Evaluate flash encryption and PSRAM encryption for the target hardware and threat model.
- Enable secure boot with a documented signing-key and recovery process.
- Define secret-safe core-dump handling; do not expose credentials or session material in diagnostics.
- Audit key and session-material zeroization across application, mbedTLS, wolfSSL, and wolfSSH allocations.
- Add authentication throttling, lockout/backoff, and security-event counters where appropriate.
- Review TLS/SSH algorithms, certificate policy, password policy, and browser security headers.
- Review current wolfSSL/wolfSSH and ESP-IDF security releases instead of treating integration versions as permanent pins.
- Recheck all third-party licenses and preserve source/license notices.
- Document provisioning, key rotation, factory reset, backup, recovery, and device decommissioning.
1. **9A — Crash/debug build policy and operational profiles — In progress; hardware pending.** `src/security_build_policy.c` requires `CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y` and `CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y`; rejects core-dump enable/flash/UART, panic print/halt/GDBstub, runtime GDBstub and ESP/FreeRTOS debugger-aware options. `sdkconfig.defaults` makes the baseline explicit. Development/test/production use the same build baseline, not separate PlatformIO environments. Host matrix (`python3 tests/security_build_policy/run.py`) compiles the actual guard: 17 cases plus the generated-header check passed on 2026-09-15. `pio run` passed (94,196 B linked RAM / 1,828,565 B flash); target panic/recovery tests have not run. Production readiness remains pending.
2. **9B — SSH admission and credential handling — Implemented; combined target validation deferred.** Boot-lifetime, owner-only token buckets independently bound handshakes, password/signed-key requests and unsigned probes; reconnect/restart/counter clearing do not replenish them. Existing per-slot attempt limits/currentness remain. Explicit keyboard-interactive rejection, pending-signature result fencing, secret-free admission counters, consumed admin-buffer wipes and fail-closed hidden-prompt overflow/unsupported-byte handling are implemented. Four focused suites (including 35 pinned-vendor control-flow cases), 11 related regressions and `pio run` passed on 2026-09-15: 94,340 B linked RAM / 1,829,925 B flash. Global-budget starvation remains a documented tradeoff, not a solved availability problem.
3. **9C — Library cleanup and protocol policy — Implemented; combined target validation deferred.** Exact-hash build-tree overrides correct HTTPS cleanup/leaks, HTTPD scratch failure/wiping/first-read handling, bounded SSH password parsing/wiping and server-local TLS policy without modifying installed dependencies. Secure wolfSSL allocation hooks and explicit SSH policy fail closed; early web quota probing avoids receiving already-throttled bodies. TLS1.2 ECDHE-ECDSA AES-GCM and SSH GCM/modern-KEX allowlists intentionally exclude legacy-only clients; no identity migration. Bounded password/certificate/header/destructor review is documented, not exhaustive zeroization. Final build PASS 94,340 B linked RAM / 1,831,309 B flash; focused and related host/source-contract tests passed. [Review and maintenance contract](security_library_review.md).
4. **9D — Maintenance and lifecycle — In progress; unresolved security and distribution questions.** [Security operations](security_operations.md) documents provisioning, explicit saves, rotation, recovery, reconstruction (no implemented backup/export) and approved destructive reset/decommissioning. The [wolfSSL/wolfSSH implementation addendum](wolf_security_review.md) records the implemented Xtensa small X25519/Ed25519 mitigation (CVE-2025-12888), with consistent library/consumer flags and a resolved-settings guard; small X25519 is not combined with blinding. Bounded IGNORE/service/string parsing, channel-window overflow, ECC/Ed25519 labels and exact signature framing are now corrected in the existing generated override. [P-256 import and X25519 all-zero-result checks](ssh_key_validation_review.md) are enabled with effective PUBLIC flags confirmed; the [finite remaining SSH parser/caller review](ssh_parser_remaining_review.md) is complete, not exhaustive safety. Exact CHANNEL_FAILURE recipient, `ssh-userauth` service and all nine channel-name checks are implemented; unknown/trailing-payload behavior is retained. PR899 client skips remain unchanged behind server ordering, forwarding is disabled, and the inspected generic caller trace has no short-digest/OID trigger; generic APIs are not globally fixed. **Restricted existing-profile ordering correction (CVE-2025-14942) is implemented**, using audited PR793/819/840/855/921 subsets plus local gates, not a full upstream backport. X25519/P-256 only, independent SELF/PEER bits, expected-message/authentication gates and queued NEWKEYS continuation handle nonblocking writes. **EXT_INFO is intentionally disabled; no `server-sig-algs` is advertised.** Nine C overrides (including the new IDF 5.5.3 WS signed-size correction) plus one PUBLIC forced ABI header retain hash pins/notices; joined forced-header arguments fix PlatformIO sorting/deduplication. The review's misplaced EOF guard is corrected and verified with no scoped blocker. The [IDF implementation addendum](idf_security_review.md) records pinned DHCP (CVE-2026-45160), TLS 1.2 EMS (CVE-2026-50581) and X.509 allocation-failure (CVE-2026-34874) backports; historical research remains labeled and retained. The [finite IDF applicability completion](idf_security_review.md#finite-applicability-completion--2026-09-16) dispositions all six named findings: ECDH small-output, zero-length ECC PK parse, basicConstraints, server NewSessionTicket and stale ASN.1 length are not current paths/configurations for their documented reasons; optimized ECC reduction is active but its privileged-local/physical side-channel attacker model is excluded. No new current-path correction was established. That earlier review screened only the first advisory-index page. The subsequent [finite dependency coverage](dependency_advisory_coverage.md) accounts for all **22 IDF entries across three pages**, plus named managed-component and browser publication channels. The declared dated search is complete, not exhaustive advisory clearance; unpatched primitives, inaccessible full-release databases and fresh findings remain.
Security features must be validated first on expendable hardware before any irreversible production configuration is recommended.
**Historical IDF 5.5.0 integration evidence:** supplied parent build PASS: **94,340 B linked RAM / 1,768,901 B flash**, unchanged RAM / **+200 B flash** versus 1,768,701 B. Final parent **all seven suites PASS**: ordering `--interop` (8,028 checks, seven rejected mutations, 12 exact-256-KiB sessions with clean channel close/transport EOF), SDK overrides with actual `--build-dir` registration, auth (135), protocol, strict crypto, notices (30), and parser (3,258 × two modes plus channel profiles; 11 + 18 + 2 rejected mutations). Interop required unsandboxed approval only for local AF_UNIX sockets; no remote network/device operation. Agent ordering evidence: **8,028 checks / seven rejected mutations**. Initial interop failed a harness close race despite early PASS output; the corrected harness waits for peer close/transport EOF and independently reaps the server through an fd-passing proxy. Final agent repeated OpenSSH matrix: **36/36 sessions**, each exact **256 KiB**, **two or ten** completed key exchanges and clean client/server exits; see [evidence and limits](security_hardening.md#ordering-hostbuild-evidence--2026-09-16).
**Release status:** the [bounded license inventory](dependency_licenses.md) marks modification/date notices resolved for all nine generated C files plus one header (baseline 2026-09-15 and wolfSSH ordering 2026-09-16; upstream licenses retained), with archived patch hashes/commits and patch license context recorded, but radio-blob corresponding-source/exception, source/notice delivery and provenance questions remain open. [Offline notice assembly](release_packaging.md) is implemented by [tools/release_notices.py](../tools/release_notices.py): 75 mandatory hash/size-pinned inputs (nine IDF/toolchain notice entries semantically rebased for the candidate; the other 66 unchanged), deterministic bounded outputs, fail-closed preflight and no overwrite/fetch/build/device access. Supplied agent evidence: **36 notice fixture tests PASS; two actual bundles verified deterministic, each 77 files / 4,433,930 bytes**. This includes retained SVGs and [exact icon provenance](icon_provenance.md), not corresponding-source delivery or legal clearance. These are agent measurements, not a parent rebuild or reassembly in this documentation turn.
**Remaining maintenance/target gates:** official registry wolfSSH 1.5.0 / wolfSSL 5.9.2 queries returned 404 on 2026-09-15; upstream tags exist but managed compatibility is not established. The [remaining ordering gates and immutable provenance](wolf_security_review.md#restricted-ordering-correction-and-remaining-gates) retain target cleanup during rekey, intended-client compatibility without EXT_INFO and whole-phase resource/timing validation. Future upstream adoption still requires isolated packaging/compatibility evaluation. Upgrades/backports require coherent source/header review, override rebasing and contract tests, not blind repinning. The [declared finite dependency search](dependency_advisory_coverage.md) is complete; fresh findings and explicit coverage gaps remain. **The fix-bearing Wi-Fi vendor bundle is integrated in the validated IDF 5.5.3 candidate; radio-hardware closure remains pending.** Follow the [Wi-Fi security update plan](wifi_security_update_plan.md) for target acceptance; do not transplant one archive, edit only the callback signature or weaken PMF/WPA3. Root migration pins are edited; complete immutable dependency closure and a successful normal root build are not established. **Phase 9 is not complete or production-ready; scoped mitigations do not establish full advisory closure, device validation or license/distribution clearance.** OTA signing trust remains separate Phase 10 work.
**Current IDF 5.5.3 integration:** fresh isolated `app-validated` build PASS, **95,552 B linked RAM / 1,749,493 B flash** (**+1,212 / 19,408 B** versus historical 94,340 / 1,768,901 B). Root selects PlatformIO 6.13.0 / framework 3.50503.0 / both toolchains 14.2.0+20251107. Default root `pio run` timed out after 200 seconds during installation, before compilation. All nine C overrides plus the header have actual-build registration evidence; generated WS tests pass **982 cases / 10 mutation checks**. See [integration and remaining gates](idf_candidate_integration.md).
**Bounded host validation:** 23 commands by default; build, OpenSSH interop and web performance are explicit options. Historical default 23/23 and 10 orchestrator fixtures passed on the earlier snapshot. Current supplied parent validation selects the isolated IDF 5.5.3 build/core/SDK, with `--interop --web-performance`: **PASS 24/24**. See [exact command, source equality and evidence limits](phase9_validation.md#current-candidate-execution--2026-09-18). This does not claim a successful default root build or whole-phase acceptance.
At the user's request, hardware validation is deferred to **Phase 9 as a whole**, not required between implementation slices. [Security hardening](security_hardening.md) collects profiles, host evidence and the combined target checklist. Silent panic reboot removes useful crash diagnostics, not ordinary reset/boot/status information or every possible log disclosure. Raw flash/RAM/dumps remain secret-bearing, not routine diagnostic exports. Existing coredump bytes are not retroactively cleared; no secure erase is claimed. Isolated synthetic-secret debug builds require explicit reviewed source-policy changes, not a provided bypass flag.
### Phase 10 — Authenticated OTA and rollback
@@ -240,7 +236,7 @@ Use the existing dual 4 MiB application slots and `otadata` partition to add saf
Planned work:
- Accept only authenticated, integrity-checked firmware images compatible with the selected secure-boot policy.
- Accept only authenticated, integrity-checked firmware images under an independently defined OTA signing-trust policy without secure boot; define trusted-key provisioning, rotation, revocation and recovery. This cannot prevent physical firmware replacement.
- Support an update path that does not expose a plaintext management endpoint.
- Stream downloads with bounded RAM use and explicit progress/error reporting.
- Preserve serial, Wi-Fi, HTTPS, and SSH configuration across successful updates.
@@ -264,7 +260,7 @@ Planned work:
- Preserve binary transparency through explicit framing and MTU-aware partial transfers.
- Bound connections, queues, retries, and advertising behavior.
- Measure coexistence with 2.4 GHz Wi-Fi, HTTPS, WebSocket, SSH, USB CDC, and UART service.
- Store bond/provisioning data under the hardened secret-storage policy.
- Define bond/provisioning secret retention under the unencrypted-storage threat model; do not assume Phase 9 provides at-rest encryption.
- Provide UART0 controls and a physical recovery path for clearing BLE state.
BLE remains subordinate to stable Wi-Fi and serial operation; it should be omitted if coexistence or memory costs cannot be bounded acceptably.
@@ -310,10 +306,10 @@ Every phase should satisfy the following before being marked complete:
The following are not implemented merely because flash partitions or library support exist:
- NVS, flash, or PSRAM encryption.
- Secure boot or production eFuse provisioning.
- Secure boot and encrypted NVS are excluded by explicit user preference, not scheduled future enablement.
- Flash/PSRAM encryption and production eFuse provisioning are outside the current scope, with no enablement commitment.
- OTA download, image confirmation, or rollback policy.
- Core-dump collection or secret-safe core-dump processing.
- Core-dump collection or export: the supported Phase 9A baseline disables new dumps; existing partition bytes are not cleared.
- Filesystem mounting.
- SFTP, SCP, SSH `exec`, forwarding, or subsystems.
+231
View File
@@ -0,0 +1,231 @@
# Security hardening — Phase 9
**Status: in progress.** Phase 8 is complete at the accepted 8D.22 scope. **9A crash/debug policy, 9B SSH admission/credential handling and 9C library cleanup/protocol policy** are implemented with host/build validation. At the user's request, hardware validation is deferred to **Phase 9 as a whole**, not an approval gate between implementation slices. **9D maintenance/lifecycle is in progress, with unresolved advisory and distribution/source/notice questions. Phase 9 is not complete or production-ready.** This document records policy and procedures, not unrun passes or production certification.
## Current migration evidence — 2026-09-18
Root `platformio.ini` now pins **PlatformIO espressif32 6.13.0 / framework-espidf 3.50503.0 (IDF 5.5.3)** and both Xtensa and RISC-V toolchains at **14.2.0+20251107**. These are exact version pins, not enforcement of archive hashes or a complete immutable build closure. The [candidate integration record](idf_candidate_integration.md) distinguishes the verified archive identities from the root configuration.
Supplied parent evidence: a **fresh isolated application build PASS** in `.pio/idf-candidate-5.5.3/app-validated`, with **95,552 B linked RAM / 1,749,493 B flash**, versus historical **94,340 / 1,768,901 B**: **+1,212 B RAM / 19,408 B flash**. The default root `pio run` timed out after **200 seconds during installation, before compilation**; it is **not a normal root build PASS**. Final explicit-candidate Phase 9 validation with interop and web performance **PASS 24/24**; see [command and snapshot evidence](phase9_validation.md#current-candidate-execution--2026-09-18). Source equality covered **3,237 files**, pre/post SHA-256 **`3a1af78c15cfdd02da1055a8957b4086f9018862b7aa1c7c52fd2401a1a0a031`**.
All **nine generated C sources plus one forced header** are validated as actual compilation inputs. The new WS correction makes five fixed-header `sizeof` comparisons signed; **982 behavioral cases / 10 mutation checks** cover generated WS receive behavior. The stale web-cookie IDF 5.5.0 fixture was corrected. [Rebase review](idf_553_rebase_review.md) retains old findings as history, not current blockers, and records all packaged Wi-Fi/PHY/coexistence/supplicant files matching the locked framework archive.
The fix-bearing vendor Wi-Fi bundle is integrated in the validated candidate, **not radio-hardware vulnerability closure**. PMF/WPA3 is unchanged. Whole-phase target/radio/resource/recovery acceptance, complete dependency closure and release/source/legal gates remain pending; **Phase 9 is not complete or production-ready**. These are supplied parent results, not builds or hardware tests rerun by this documentation update.
## Scope and threat model
Reduce network abuse, accidental diagnostic disclosure and unnecessary secret retention while preserving one UART1 broker writer, isolated observers and binary transparency. UART0 remains trusted physical administration/recovery; native USB remains network-independent UART1 access, not an admin console. Whole-device reboot interrupts every transport.
Secure boot and encrypted NVS are explicitly excluded by user preference. Physical flash/RAM extraction and firmware replacement remain outside the threat model even after Phase 9. There is no commitment to flash/PSRAM encryption, eFuse provisioning or physical JTAG restrictions. Software debugger-aware configuration checks do not disable physical debug access by fuse.
9A does not change partitions, at-rest encryption, dependencies or generated assets, and requires no upload or erase as part of host/build validation. The unused `nvs_key` and `coredump` partitions remain for layout compatibility. Disabling new dumps does not clear old coredump contents. Logical NVS replacement, reset and credential rotation are not secure erasure; historical plaintext copies can remain.
## 9A supported build baseline
`src/security_build_policy.c` enforces the following at compile time, with explicit settings in `sdkconfig.defaults`:
- Require `CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y` and `CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y`.
- Reject enabled core-dump support, flash or UART dump destinations.
- Reject panic print/reboot, print/halt and GDBstub modes, plus runtime GDBstub.
- Reject `CONFIG_ESP_DEBUG_OCDAWARE` and `CONFIG_FREERTOS_DEBUG_OCDAWARE` when enabled.
The host matrix in `tests/security_build_policy/run.py` compiles the actual guard against synthetic configurations; it is not merely a text check of defaults. Existing generated SDK configuration must also satisfy the guard: defaults alone are not evidence of the effective build configuration.
Silent panic reboot deliberately sacrifices panic text, register dumps and backtraces for reduced crash disclosure. Reset-reason/boot information and ordinary status/logging can remain; neither silence across the full boot sequence nor general log redaction is guaranteed. A monitor exception decoder cannot reconstruct a backtrace that was never emitted.
## 9B SSH admission and credential handling
### Boot-lifetime admission budgets
`src/ssh_auth_policy.{c,h}` owns three independent, fixed-size token buckets. Only the SSH owner task accesses the shared 72-byte policy; no allocation, per-peer/account map, timer task or sleep is added.
| Admission class | Initial/maximum burst | Refill |
|---|---:|---|
| New SSH handshake | 6 | One token per 10 seconds |
| Password or signed-key authentication request | 6 | One token per 10 seconds |
| Unsigned public-key probe | 12 | One token per 5 seconds |
These are **burst-plus-refill limits**, not six/twelve requests in every rolling minute. All peers/accounts and both slots share each class. Idle refill stops at capacity; denials do not extend the refill deadline. Reconnects, service stop/start, identity rotation and `ssh clear-counters` do not replenish the pools. Reboot starts a new policy lifetime. Timestamp regression fails closed. No persistent account lockout or NVS write is introduced.
- A handshake token is taken after finding capacity but before `wolfSSH_new()`/handshake work. Full-capacity rejection takes no token; later allocation/IO failure does not refund it.
- Password/signed-key admission precedes database verification/authorization and ordinary key signature work. Success, invalid credentials, backend errors and rejected password-change requests do not refund admission. Unsigned probes use their own pool and cannot authenticate.
- Exhaustion shuts down/rejects the new or authenticating connection without waiting inside the owner task. Already-authenticated streams do not pass through this admission gate. The existing three-counted-attempt failure closure, two-slot bound and 15-second handshake deadline remain.
- **Availability tradeoff:** a client can consume the handshake burst by opening/abandoning connections and race legitimate clients for each refill. Global verification/probe pools can also starve other users. This bounds admitted work, not fair access or immunity to denial of service. TCP accept/rejection work and library parsing still occur; target latency under abuse is not yet measured. Restrict network access, stop the offending traffic and allow natural refill rather than repeatedly reconnecting/restarting. UART0/USB remain independent of these pools; HTTPS keeps its separate policy.
### Callback and library contract
`src/ssh_transport.c` requires wolfSSH 1.4.20, certificates disabled and `none` authentication disabled at compile time. The reviewed parser calls ordinary-key authorization before signature verification; rejected authorizations and unsigned probes have no result callback. Password results are completed within the password callback. An explicit pending-result marker fences signed-key completion; duplicate/unexpected/closing-session results cannot promote a principal or count another completed attempt. Principal currentness is still checked at successful signature completion and route admission.
Advertising only password/publickey is not a dispatch filter in this wolfSSH version. An explicit rejecting keyboard-interactive prompt callback and per-slot context prevent its unregistered-callback path; it creates/sends no prompts and closes the connection. The advertised methods remain password/publickey. This does not certify every malformed-packet path in the library.
`tests/wolfssh_auth_contract/run.py` checks the reviewed `internal.c` SHA-256 and version, preprocesses the actual build's feature profile, and executes extracted vendor parser/send functions with narrow crypto/IO doubles. A same-version source change requires re-audit, not blindly replacing the hash. It does not replace real-client/cryptographic integration testing. The positive `SendChannelData()` return contract means the caller's accepted prefix has been copied, including its consumed-data WANT_WRITE case; it is not peer acknowledgement.
### Counters and secret lifetime
`ssh counters` adds aggregate-only diagnostics:
- `handshakes` / `handshake-throttled`: admitted handshake work / rate-denied connections, separate from capacity failures.
- `verifications` / `verification-throttled`: admitted password/signed-key requests / rate-denied requests. Admission does not imply the verifier ran or completed.
- `probes` / `probe-throttled`: admitted/denied unsigned-key lookups, not completed credential attempts.
- `attempt-limit-closes`, `backend-errors`, `method-rejects`: three-attempt closures, database auth/authorization/currentness errors, and rejected callback-level methods (including keyboard). These are not counts of every malformed SSH packet.
Existing `auth-attempts`/`auth-failures` remain completed counted outcomes; rejected password changes count, unsigned probes and rate-denied requests do not. Signed-key results finalize once after authorized work. These admission/auth counters saturate at `UINT64_MAX`, contain no submitted credentials/identities, and may be cleared independently of enforcement state.
The transport now wipes consumed admin RX bytes, positively accepted admin TX bytes, and the full retired slot while retaining its generation. Partial/retry paths preserve pending bytes. Serial-route hot-path behavior is unchanged. This shortens application plaintext lifetime; it is not a claim that wolfSSH/wolfSSL/mbedTLS, stack or PSRAM copies are all erased.
Hidden UART0 and shared remote-console prompts now reject overflow or unsupported bytes on submission with a wiped output buffer and `ESP_ERR_INVALID_SIZE`, rather than accepting a truncated/normalized prefix. The failure remains sticky after Backspace/Delete. Printable ASCII, CR/LF submission, Backspace/Delete and Ctrl-C retain their defined roles; visible command-line editing is unchanged. Existing callers prevent a rejected password or confirmation from reaching persistence. For pasted passwords, exceeding 64 characters or including unsupported bytes requires a fresh attempt; the password policy itself is unchanged.
## 9C library cleanup and protocol policy
[Library review and maintenance contract](security_library_review.md) records the scoped audit, corrected paths, existing cleanup and limits. This is not exhaustive library certification or a dependency security-release review.
### Reproducible source corrections
`tools/security_overrides.py` verifies full original-file SHA-256 values and ESP-IDF 5.5.3, applies exact-once edits, and generates nine corrected C sources plus one header under the build directory (the original four 9C sources, three IDF advisory sources, the additional wolfSSH `ssh.c`/`internal.h` ordering inputs, and the IDF 5.5.3 WS signed-receive correction). `cmake/security_overrides.cmake`, included after `project()`, replaces exactly the corresponding sources in existing IDF/component targets, retaining compilation properties. Installed SDK/managed sources and their notices remain unchanged. Missing, changed or ambiguous sources fail configuration; there is no unpatched fallback. Do not edit derived files or repin a hash merely to make an upgrade build.
- **HTTPS:** delete TLS on post-handshake transport-allocation failure; fully destroy retained TLS configuration on failed HTTPD start; wipe the copied raw private key before free. Failed stop still retains live ownership.
- **HTTPD parser:** allocate/copy/wipe/free scratch on resize, preserve the old pointer on allocation failure, wipe final scratch, and handle the null initial parser pointer without undefined subtraction. Pending/unread bytes retain their existing behavior.
- **wolfSSH password parser:** bound both password lengths against the actual packet before application callbacks, reject malformed change-password fields without calling authentication, and wipe the bounded method-specific payload suffix before failure responses. Username/service/method prefixes remain intact. The current project callbacks are synchronous; library `WS_AUTH_PENDING` retains the payload for retry and is not claimed wiped.
- **ESP-TLS server configuration:** enforce the static-lifetime TLS list below before handshake setup; client defaults and global cryptographic primitives remain unchanged. IDF dynamic TLS buffers are rejected because their cleanup bypasses the reviewed upstream record-buffer wipe.
The new `src/ssh_memory.{c,h}` wolfSSL/wolfCrypt allocation hooks wipe the full owned usable allocation before release, including library import-failure and dynamic packet-buffer copies. They require the reviewed unpoisoned IDF 5.5.3 heap configuration; poisoning modes fail compilation rather than risking canary writes. No allocation header is added. Shrink retains capacity and wipes the tail; growth allocates/copies before wiping/freeing the old block, preserving it on allocation failure. PSRAM preference/internal fallback is unchanged. **Growth and HTTPD scratch resizing temporarily need both blocks; lower linked size is not evidence of safe runtime headroom.** Live inline buffers, in-place compaction tails, stack spills and every crypto intermediate are not comprehensively covered.
### Explicit network protocol policy
| Setting | Allowed values, in preference order |
|---|---|
| HTTPS version | TLS 1.2 only; server renegotiation disabled |
| HTTPS suites | `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`, `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` |
| SSH KEX | `curve25519-sha256`, `ecdh-sha2-nistp256` |
| SSH host key | `ecdsa-sha2-nistp256` |
| SSH ciphers, both directions | `aes128-gcm@openssh.com`, `aes256-gcm@openssh.com` |
| SSH MAC advertisement | `hmac-sha2-256` (GCM supplies packet authentication) |
| SSH user-key policy | `ssh-ed25519`, `ecdsa-sha2-nistp256`; database authorization remains authoritative; no EXT_INFO/`server-sig-algs` advertisement |
`src/ssh_protocol_policy.c` uses permanent strings and checks every setter; any failure destroys the unpublished context without fallback. Tests verify the actual compiler's available algorithms and generated KEXINIT lists, not merely setter success. The server-only TLS correction avoids breaking future outbound HTTPS clients by globally removing RSA-certificate suites.
**Compatibility:** TLS CBC/CCM/ARIA-only and SSH CBC/CTR-only clients no longer connect; removed KEX-only clients also fail. Bounded OpenSSH host interoperability is recorded below; intended-client compatibility without EXT_INFO and rekey/cleanup on hardware remain target gates. RSA-SHA2 discovery is not claimed. Existing TLS/SSH identity and user-key storage need no rotation or migration. Password/KDF, certificate validity/trust and browser-header policy were reviewed and retained with documented limits; no blind KDF-cost increase or HSTS policy was introduced.
### Web admission and shorter plaintext lifetimes
A non-consuming quota/epoch check now runs after valid challenge consumption but before body receive. An already-exhausted verification budget returns 429/`Retry-After` without receiving/parsing credentials or calling the verifier; unread bodies still cause connection closure, not draining. The authoritative reservation remains after parsing. Raw JSON is wiped before verification, parsed credentials afterward, and both before error-response sending. Header strings remain live through synchronous serialization.
The existing global five-verifications/60-second fixed window is unchanged: malformed requests do not charge it, counter clear does not replenish it, and web service restart does. This differs deliberately from SSH's boot-lifetime buckets. The first boot-minute window remains anchored at uptime zero. Challenge monopolization, global-budget starvation and malformed-body work while budget is available are not solved by this early check.
## 9D maintenance and lifecycle — in progress
- [Security operations](security_operations.md) supplies source-checked provisioning, explicit-save, account/key/identity rotation, recovery, reconstruction and approved erase/decommissioning procedures. There is no implemented configuration backup/export or private-identity restore workflow. Commands and checklists are not device execution evidence; the user will validate Phase 9 as a whole.
- [wolfSSL/wolfSSH review and implementation addendum](wolf_security_review.md): the **CVE-2025-12888** Xtensa mitigation now selects `CURVE25519_SMALL` and `ED25519_SMALL` consistently for wolfSSL and consumers. The PUBLIC forced-include resolved-settings guard rejects missing small implementations, X25519 blinding (incompatible with small in this pinned version), and unreviewed Curve448/Ed448 enablement. The existing generated wolfSSH override now bounds IGNORE/service strings, rejects zero-capacity string output and channel-window overflow, corrects ECC/Ed25519 key/signature labels, and enforces exact signature framing (ECC nested r/s bounds plus inner/outer consumption; Ed25519 outer consumption). These are PR892/881/880 subsets plus local framing corrections, not full backports; password wiping/async retention remain unchanged. PUBLIC `WOLFSSL_VALIDATE_ECC_IMPORT` and `WOLFSSL_ECDHX_SHARED_NOT_ZERO` now enable P-256 import validation and X25519 all-zero-result rejection, with effective production flags confirmed and fail-closed backend guards. See [key-validation evidence](ssh_key_validation_review.md) and [parser scope/limits](../tests/wolfssh_parser_contract/README.md). The restricted existing-profile correction for **CVE-2025-14942** is implemented as described below. The [finite remaining SSH review](ssh_parser_remaining_review.md) is complete: bounded exact CHANNEL_FAILURE recipient parsing (fatal policy retained), exact `ssh-userauth` service validation, and exact length/byte dispatch for all nine channel-request names are implemented. Unknown-request/trailing-payload behavior is preserved. PR899 client key skips are unchanged and blocked by current server role/ordering; PR918/919 forwarding is disabled. The inspected generic signature caller trace found no attacker-selected short-digest/OID path; generic APIs remain unpatched. These are profile-specific dispositions, not exhaustive parser/library safety; revisit on caller, feature, KEX or source changes. No exploit or whole-library clearance is demonstrated; added validation CPU/allocation cost and target interoperability remain unmeasured.
- [Focused IDF review and implementation addendum](idf_security_review.md): pinned backports now implement DHCP option bounds (**CVE-2026-45160**), TLS 1.2 EMS failure return (**CVE-2026-50581**) and X.509 OID allocation-failure handling (**CVE-2026-34874**). Explicit nested-target validation places the mbedTLS edits on `mbedtls`/`mbedx509`, retaining source properties and exactly-one-source checks. WS negotiation **CVE-2026-45541** and ASN.1 named-data **CVE-2025-48965** remain unpatched with the review's qualified applicability, not blanket closure. The [finite IDF applicability completion](idf_security_review.md#finite-applicability-completion--2026-09-16) dispositions all six named findings: ECDH small-output, zero-length ECC PK parse, basicConstraints, server NewSessionTicket and stale ASN.1 length are not current paths/configurations for their documented reasons; optimized ECC reduction is active but its privileged-local/physical side-channel attacker model is excluded. No new current-path correction was established. That earlier review screened only the first advisory-index page. The subsequent [finite dependency coverage](dependency_advisory_coverage.md) accounts for all **22 IDF entries across three pages**, plus named managed-component and browser publication channels. The declared dated search is complete, not exhaustive advisory clearance; unpatched primitives, inaccessible full-release databases and fresh findings remain.
- [Dependency license inventory](dependency_licenses.md) is a bounded engineering inventory, not legal or distribution clearance. All **nine generated C sources plus one header** carry prominent modification notices: the baseline **2026-09-15** notice plus **2026-09-16** ordering/provenance notices on wolfSSH outputs, with upstream notices retained, including both mbedTLS dual-license headers. That narrow finding is resolved. Radio-blob corresponding-source/exception questions, actual firmware/device/browser notice delivery, preferred-source packaging, wolfSSH package-license discrepancy and manual bitmap derivation, the distinct mockup Wi-Fi path and recipient license delivery remain open; [exact retained SVG provenance](icon_provenance.md) is resolved. Notice assembly does not prove recipient delivery. [Offline notice assembly](release_packaging.md) is implemented by [tools/release_notices.py](../tools/release_notices.py): 75 mandatory hash/size-pinned inputs (nine IDF/toolchain notice entries semantically rebased for the candidate; the other 66 unchanged), deterministic bounded outputs, fail-closed preflight and no overwrite/fetch/build/device access. Supplied agent evidence: **36 notice fixture tests PASS; two actual bundles verified deterministic, each 77 files / 4,433,930 bytes**. This includes retained SVGs and [exact icon provenance](icon_provenance.md), not corresponding-source delivery or legal clearance. These are agent measurements, not a parent rebuild or reassembly in this documentation turn.
- **Restricted ordering correction implemented, not a full upstream backport or sign-off.** The [ordering review](wolf_security_review.md#restricted-ordering-correction-and-remaining-gates) and [provenance/prerequisite disposition](../tools/wolfssh_order/README.md) document the audited PR793/819/840/855/921 subsets plus local gates. Existing X25519/P-256 KEX only; independent SELF/PEER state, exact expected replies and authentication-phase checks cover both roles. Queued NEWKEYS survives WANT_WRITE without duplication. **EXT_INFO is deliberately disabled; no `server-sig-algs` is sent**, and `extInfoSent` stays zero. CMake applies the generated ABI header BEFORE PUBLIC and via a PUBLIC forced include; joined `-include/path` flags fix PlatformIO sorting/deduplication for ordering and crypto guards. Review's misplaced EOF guard is corrected before channel mutation; verification found no scoped blocker. Target cleanup during rekey and no-EXT_INFO client compatibility remain pending.
- No dependency versions were upgraded. Beyond the implemented backports above, proposed upgrades/backports remain candidates, not approved compatible versions. Re-audit coherent source/header changes, effective compile policy, exact-hash overrides and callback/parser contracts, then obtain host/build and whole-phase target evidence. 9A9C passes below are historical scoped evidence, not closure of these newly recorded findings.
Secure boot and encrypted NVS remain excluded. No runbook, advisory report or license inventory establishes production readiness or authorizes a destructive device operation.
## Operational profiles
These are handling and validation profiles of the **same supported build baseline**, not separate PlatformIO environments or selectable security overrides.
| Profile | Operational rules |
|---|---|
| Development | Keep the guard enabled; use synthetic credentials and controlled serial payloads for fault investigation. Keep UART0 recovery available. Review captures before sharing. |
| Test | Use an isolated, expendable target and synthetic secrets; record exact source/configuration, host/build results and device observations. Exercise crashes and recovery without exporting raw memory. |
| Production | Use the same guard, restrict physical/network access, verify device identity through trusted UART0, and apply reviewed provisioning/rotation/recovery procedures. Readiness remains pending Phase 9 review and target evidence. |
If richer crash debugging is essential, use an isolated synthetic-secret build outside this supported baseline. It requires explicit reviewed changes to the source policy and applicable configuration; no bypass flag is provided. Do not use real credentials or deploy that build as production firmware. Restore and revalidate the supported policy before release.
Raw flash, RAM and dumps can contain Wi-Fi passwords, private keys, password verifiers, session material and serial payloads. Treat them as secret-bearing and **do not export them as routine diagnostics**. Prefer bounded status/counter observations and reviewed synthetic-secret reproductions. Restrict any exceptional artifacts and define retention/deletion before collecting them; deletion is not a secure-erase guarantee.
## Validation gates
### Ordering host/build evidence — 2026-09-16
Supplied parent `pio run` **PASS**: **94,340 B linked RAM / 1,768,901 B flash**, unchanged RAM / **+200 B flash** versus 1,768,701 B. **Supplied final parent results: all seven suites PASS** — ordering `--interop` (8,028 checks, seven rejected mutations, 12 sessions with exact 256 KiB echo each and clean channel close plus transport EOF), SDK overrides `--build-dir .pio/build/esp32-s3-devkitc-1-n16r8`, auth (135 cases), protocol, strict crypto, notices (30), and parser (3,258 cases in each of two stack modes plus channel profiles; 11 + 18 + 2 rejected mutations). Interop required unsandboxed approval solely for local AF_UNIX sockets; no remote network or device operation occurred. This documentation update did not rerun firmware or host suites.
Supplied agent ordering tests passed **8,028 checks and seven rejected mutations**, including the corrected EOF guard and real shutdown/exit-status rekey fences. The installed PlatformIO/SCons adapter regression validates joined forced-header flags with a real Xtensa consumer and rejects a split-option mutation. The [test README and code](../tests/wolfssh_order_contract/README.md) describe full generated translation units, real wolfCrypt, message-ID matrices, fragmented writes and both roles/rekey directions.
Initial host interoperability failed a harness close race: early `INTEROP PASS` preceded OpenSSH `Broken pipe` and was not a pass. The harness now waits for peer channel close and transport EOF, passes a local socket descriptor to OpenSSH, independently owns/reaps the server, and checks both process exits. Final agent `python3 tests/wolfssh_order_contract/run.py --interop --interop-repeat 3` evidence: **36/36 sessions**, each exact **256 KiB** binary echo, **ten** key exchanges in client-rekey cases or **two** in fragmented server-rekey cases, clean exits and no EXT_INFO. Coverage uses OpenSSH 10.2p1, both KEX algorithms, Ed25519/P-256/password authentication and AES128-GCM. It is not general library shutdown, arbitrary-client or target evidence. Whole-phase gates remain pending.
The [source-authoritative parser report](ssh_parser_remaining_review.md#validation-and-remaining-handoff) and [test contract](../tests/wolfssh_parser_contract/README.md) split the channel matrix from the 3,258 base cases: **2,737 per stack mode** for TERM-only, TERM+SHELL and TERM+SHELL+AGENT; **2,735 per stack mode** for no-terminal and SHELL-only. All five profiles run both modes; alternate features are host fixtures, not firmware enablement. Mutations are **11 base + 18 name/length + 2 real application admission gates**. Parent ordering adds **12 OpenSSH sessions**, each exact **256 KiB**, rekey and clean client/server exit; SDK tests checked actual build registration. These integrated parent results supersede the parser report's earlier stale-build handoff, without changing its historical execution record. Independent review reported no actionable defects within the scoped parser/bundle review, not a Phase 9 approval.
### Mitigation host/build evidence — 2026-09-15
Supplied parent results (not rerun for this documentation update): `pio run` **PASS**, **94,340 B linked RAM / 1,768,949 B flash**. RAM is unchanged and flash is **1,732 B larger** than the preceding 1,767,217 B mitigation build. Linked size is not runtime headroom or timing evidence.
All five parent commands passed (crypto policy in strict mode, without candidate injection):
```sh
CCACHE_DISABLE=1 python3 tests/sdk_security_overrides/run.py --build-dir .pio/build/esp32-s3-devkitc-1-n16r8
CCACHE_DISABLE=1 python3 tests/wolf_crypto_policy/run.py
CCACHE_DISABLE=1 python3 tests/wolfssh_parser_contract/run.py
CCACHE_DISABLE=1 python3 tests/wolfssh_auth_contract/run.py
CCACHE_DISABLE=1 python3 tests/ssh_protocol_policy/run.py
```
Independent review found no blocker in the scoped changes. The parser suite passed **3,124 cases per stack mode (two modes)** with guard pages/UBSan trap instrumentation and **six rejected guard-removal mutations**; its crypto doubles establish parser gating, not signature arithmetic. The auth suite passed **135 cases**. Strict crypto tests run real vendor arithmetic/ASN vectors, independently compare seven audited source bodies with exact parser deltas, and check twelve production translation units plus negative policy cases. Effective ECC/X25519 flags were confirmed. SDK override validation includes actual seven-source build registration. This is scoped implementation/host/build evidence, not ordering closure, exhaustive parser/crypto review, license clearance or whole-phase acceptance. No target evidence, upgrade, asset regeneration or device operation is claimed.
### Host and build — historical passes 2026-09-15 (9A9C)
From the repository root:
```sh
python3 tests/security_build_policy/run.py
python3 tests/ssh_auth_policy/run.py
python3 tests/ssh_auth_transport/run.py
python3 tests/hidden_input/run.py
pio run
python3 tests/security_build_policy/run.py --sdkconfig-header .pio/build/esp32-s3-devkitc-1-n16r8/config/sdkconfig.h
python3 tests/wolfssh_auth_contract/run.py
python3 tests/sdk_security_overrides/run.py --build-dir .pio/build/esp32-s3-devkitc-1-n16r8
python3 tests/ssh_memory/run.py
python3 tests/ssh_protocol_policy/run.py
python3 tests/web_cookie_auth/run.py --admission
```
Historical final 9C `pio run` passed with **94,340 B linked RAM / 1,831,309 B flash**, unchanged linked RAM / +1,384 B flash against 9B. This is linked size, not measured runtime headroom. Five focused suites passed after final HTTPD first-read correction: pinned SDK cleanup/TLS/source registration, 135 generated wolfSSH parser/control-flow cases, secure allocator, SSH policy (including 15 actual context-integration cases), and web early admission/wiping. The allocator's optional installed-SDK extent contract was also run with the installed IDF path and passed; plain invocation reports that optional check skipped. Seventeen integrated regression commands passed before the final first-read addition, including SSH auth/management, HTTPD idle, HTTPS lifecycle, five cookie-auth modes and the 18-case crash-policy matrix. All ten existing cookie-auth domain modes also passed during implementation. Independent reviews found no blocking issues; the identified inherited first-read pointer issue was corrected and tested. Use `CCACHE_DISABLE=1` on host commands if the compiler wrapper's cache is read-only in a sandbox. No upload, erase, eFuse operation or target test was performed.
Record the revision, compiler/build outcome and effective configuration. Confirm that the matrix accepts the supported configuration, rejects each prohibited option independently, and rejects absent/disabled required settings. Confirm the normal firmware build compiles the guard. A rejected unsafe configuration is an expected negative-test result, not a firmware build pass. Neither these commands nor a successful build proves target panic behavior.
### Combined Phase 9 target validation — deferred, not run
Retain these checks for the user's final whole-phase test session; do not stop implementation for separate slice sign-off. Include the [9D operational rehearsal](security_operations.md#deferred-whole-phase-9-hardware-rehearsal) and targeted message-order/parser/key-validation/interoperability checks for the implemented restricted mitigations and any subsequent reviewed changes. None is recorded as passed here.
#### Crash and recovery
1. On an isolated synthetic-secret target, record the tested image/configuration and capture UART0 at 115200 baud. Verify normal boot, UART0 administration, native USB UART1 access, HTTPS and SSH before fault testing.
2. Through separately reviewed test-only fault injection, trigger a controlled panic with the supported build policy intact. Verify reboot rather than halt/debugger wait, no panic register/backtrace output and no UART core dump. Record any remaining boot/reset information; do not promise complete UART silence.
3. Verify no new flash core dump is written using a reviewed target-side pass/fail check that does not export partition contents. Distinguish old partition contents from a new write; do not erase the partition merely to claim this test passed.
4. After reboot, verify UART0 recovery and USB serial access, then authenticated HTTPS/SSH and broker writer/observer behavior. With network services unavailable, verify UART0 and native USB still work. Review routine status/log output using synthetic secrets; this is bounded evidence, not universal redaction proof.
5. Record outcomes and limitations in the combined Phase 9 acceptance. Device flashing/fault injection requires a separately authorized hardware session; no eFuse changes, partition migration or erase is required by this policy.
#### Authentication, input and loaded isolation
1. On a restricted test network using synthetic credentials, exercise password and Ed25519/P-256 key login for both roles, including a client offering multiple keys. Verify unsigned probes, wrong passwords/signatures, stale-principal rejection and normal shell admission. Explicit keyboard-interactive requests must close/reject without a crash or prompt.
2. Exhaust each admission class separately, respecting the independent budgets. For verification testing reuse admitted connections (up to the existing three-failure limit) so handshake exhaustion does not mask the verification gate. Verify counter deltas, reconnect resistance, natural refill and that successful logins also consume capacity. Unsigned probes must not increase completed `auth-attempts`.
3. From UART0, clear counters and stop/start SSH while exhausted; observe that neither grants fresh tokens. Account for time elapsed during these operations. Do not assume that a reconnect failure indicates bad credentials. A quiet 60-second period replenishes all pools; ongoing hostile traffic can keep them depleted.
4. Keep an established SSH serial stream and USB/browser clients active while generating bounded invalid-login/reconnect traffic. Record serial/broker drops, UART0 command latency, SSH stream responsiveness, internal/DMA minima and recovery. Do not use this admission policy to claim zero CPU impact; TCP/kernel work, KDF/signature work within budget and two-slot occupancy still matter.
5. Test hidden credentials at maximum length and one byte over, different suffixes past the limit, unsupported input bytes, overflow followed by editing, Ctrl-C, disconnect and confirmation failure on UART0 and remote administration. No rejected prefix may be persisted or echoed. Check both CR/LF behavior, including delayed UART0 LF delivery: the current UART0 reader relies on next-prompt input flushing, unlike the remote reader's explicit paired-LF handling; host fakes do not prove device timing.
6. Exercise generated-password delivery with slow/partial remote output and short subsequent commands, then disconnect/reconnect. Application-buffer wipe assertions are host evidence; do not export live RAM to establish a device pass.
#### Protocol compatibility and allocation-failure recovery
1. Verify both allowed TLS suites and both SSH GCM ciphers using compatible clients; force excluded CBC/CTR/other-only offers and confirm rejection. Exercise both SSH KEX choices and both user-key types, initial handshake and rekey, plus TLS renegotiation rejection. Verify intended clients work without EXT_INFO/`server-sig-algs`; do not assume RSA-SHA2 discovery. Exercise disconnect/cleanup during rekey and subsequent session recovery under load. Retain UART0 access; do not rotate identities to work around an algorithm mismatch.
2. With synthetic credentials, test truncated/oversized SSH password and change-password packets: no authentication callback for malformed fields, no crash, bounded disconnect/recovery. Include malformed IGNORE/service strings, window overflow, ECC/Ed25519 labels and nested/trailing signature bytes, invalid P-256 points and low-order X25519 inputs. Measure added import-validation latency/allocation/stack cost, host-key loading and handshake deadlines under repeated KEX/rekey and combined load. Host canary/vector assertions are not real encrypted-packet coverage.
3. Exercise HTTPS failed-start, post-handshake allocation failure, normal/failed-stop retry and split-header scratch allocation failure on a separately reviewed fault-injection image. Observe recovery/no accumulating allocation loss without exporting keys or RAM. Failed stop must not prematurely free live TLS state.
4. Repeatedly start/stop HTTPS and SSH and stress header parsing/authentication under the full transport mix. Capture internal/DMA/PSRAM free/minimum/largest-block and stack margins alongside serial/broker loss counters. Specifically measure old-plus-new allocation peaks and secure-free CPU cost; previous very low internal minima remain important.
5. Verify exhausted web login returns early without stalled-body work, clears the used pre-login challenge, and recovers after the documented window. Check malformed requests below quota and correct credentials for normal behavior; do not infer fairness from a rate-limit pass.
**Bounded host validation:** 23 commands by default; build, OpenSSH interop and web performance are explicit options. Historical default 23/23 and 10 orchestrator fixtures passed on the earlier snapshot. Current supplied parent validation selects the isolated IDF 5.5.3 build/core/SDK, with `--interop --web-performance`: **PASS 24/24**. See [exact command, source equality and evidence limits](phase9_validation.md#current-candidate-execution--2026-09-18). This does not claim a successful default root build or whole-phase acceptance.
## Staged next work
- **Implementation/maintenance gate:** the finite SSH and six-finding IDF reviews are complete for their stated profiles; do not re-list them as unimplemented. The [declared finite advisory search](dependency_advisory_coverage.md) is complete. The fix-bearing vendor bundle is integrated in the validated IDF 5.5.3 candidate; complete the [remaining Wi-Fi target gates](wifi_security_update_plan.md), retaining PMF/WPA3 and exact override guards. Integration is not radio-hardware closure. Residual direct-SDK AES-DMA applicability and publication-channel gaps remain; recheck the bounded snapshot before release. Optional ASN.1/ECDH/basicConstraints defense-in-depth backports are not implemented or required by a demonstrated current-path finding.
- **Release gate:** notice assembly is implemented; validate actual firmware/device/browser delivery, corresponding source and preferred asset sources, radio-blob legal basis, wolfSSH packaging clarification, remaining manual bitmap/mockup provenance, final runtime/bootloader attribution and any Installation Information. See [packaging gates](release_packaging.md#separate-work-before-distribution).
- **Target/acceptance gate:** rehearse operations and the combined target checklist above, including panic/recovery, abuse/isolation, cleanup during rekey, intended-client compatibility without EXT_INFO, loaded KEX/rekey and heap/stack/CPU timing. Obtain explicit whole-phase acceptance; no scoped reviewer or host PASS can substitute.
- **Continue 9D maintenance and lifecycle.** Execute the [remaining ordering gates and advisory work](wolf_security_review.md#restricted-ordering-correction-and-remaining-gates), target-validate the integrated coherent Wi-Fi correction identified by the completed finite dependency search, and revisit its explicit residual questions, and address the [release source/notice work](dependency_licenses.md#actionable-release-work-not-performed). Runbooks are documented, not rehearsed; remaining mitigations/reviews, distribution clearance and whole-phase acceptance remain outstanding.
- **Retained evidence limits:** 9C completes a bounded cleanup/protocol review, not every-library-copy zeroization. Live inline residue, compaction tails, hardware/stack intermediates, global admission starvation and resource/interop measurements remain documented limitations or combined target gates. Any additional hardening must preserve owner lifetimes and bounded recovery.
- **Phase 10: OTA trust.** Define independent image-signature verification, trust-anchor provisioning, rotation/revocation, rollback/downgrade and recovery policy without secure boot. Authenticated transport alone is not image-signing policy, and OTA signature checks cannot prevent physical firmware replacement.
See the [roadmap](roadmap.md#phase-9--security-and-production-hardening), [electrical procedures](electrical_tests.md) and [administration regressions](user_administration_tests.md) for wider gates. Production readiness remains pending; Phase 8 acceptance is not reopened by these follow-ups.
+143
View File
@@ -0,0 +1,143 @@
# Security library review — Phase 9C
## Scope and status
Bounded implementation/source audit, verified 2026-09-15; not library security certification.
Baseline: **ESP-IDF 5.5.0, mbedTLS 3.6.3, wolfSSH 1.4.20, wolfSSL 5.8.2~1**
(upstream wolfSSL version macro: 5.8.2). Original dependencies are not upgraded or hand-patched.
Versions were checked against installed headers and `src/idf_component.yml`; override hashes
were checked against installed originals. Source is authoritative over older integration notes.
The reported Phase 9C reviews have no remaining blocking finding; the HTTPD null-initial
read finding is fixed and covered by the passing host suite below.
Whole-Phase-9 target validation is deferred at the user's request; see [main policy](security_hardening.md).
The [main policy](security_hardening.md#validation-gates) records final firmware build/size evidence separately from this source review.
## Confirmed gaps fixed
| Boundary / source | Implemented correction |
|---|---|
| SDK `esp_https_server/src/https_server.c` | Delete TLS when post-handshake transport allocation fails; destroy the complete secure context on HTTPD start failure. Restore the original open callback and clear stale context/destructor pointers. Wipe `serverkey_bytes` before freeing the raw key copy. Failed stop retains live ownership. |
| SDK `esp_http_server/src/httpd_parse.c` | Replace scratch realloc with allocate/copy/wipe/free; preserve old pointer/content on allocation failure and wipe current scratch at final cleanup. Preserve pending/unread bytes. Initial reads avoid NULL subtraction and retain a NULL parser position until set; existing positions relocate correctly. |
| SDK `esp-tls/esp_tls_mbedtls.c` | Apply the server-local TLS profile below after defaults and before setup; static suite storage, TLS 1.2 minimum/maximum, no renegotiation. Client defaults/caller suites and global crypto features are unchanged. |
| wolfSSH `src/internal.c` | Use `GetSize()` bounds for password/new-password fields, reject invalid context/index, and guard authentication dispatch after new-password parse failure. Wipe the checked packet suffix before failure output, preserving the username/service/method prefix needed by the caller. Skip wiping on `WS_AUTH_PENDING` for retry; the project does not return pending. |
| `src/ssh_memory.c`, `src/ssh_transport.c` | Register secure wolfSSL/wolfSSH allocation hooks before library allocation; wipe retired heap extents and explicit shrink tails, including allocator rounding. |
| `src/ssh_protocol_policy.c`, `src/ssh_transport.c` | Apply all five explicit lists; any setter failure frees the unpublished candidate and returns failure, without default-policy fallback. |
| `src/web_cookie_auth.c` | Check exhausted verification budget before body receive/parse, reserve authoritatively after parsing, and shorten JSON/credential lifetime before backend/error output. |
The three SDK overrides and wolfSSH override are registered in `tools/security_overrides.py`.
Root `CMakeLists.txt` includes `cmake/security_overrides.cmake` **after `project()`**;
`src/CMakeLists.txt` includes both new SSH modules. No embedded web assets were regenerated.
## Heap and packet lifetime contract
`ssh_memory` compile-guards **unpoisoned IDF 5.5.0**: `heap_caps_get_allocated_size()`
must return the owned usable extent of a base allocation, not an interior-pointer extent.
Allocation remains PSRAM-first with internal fallback; no allocation headers, metadata tables,
extra locks or tasks are introduced. Free securely wipes the complete extent before release.
Shrink retains the pointer/capacity and wipes the discarded tail; it does not reclaim heap.
Growth allocates a replacement, copies the old usable extent, then wipes/frees the old allocation.
Failed growth leaves the old allocation and contents unchanged. Growth temporarily needs **old + new**
storage, including possible internal fallback. HTTPD resize similarly needs both bounded allocations,
but retains its ordinary shrink/grow behavior rather than a permanent maximum-sized scratch buffer.
These fixes cover specific retired copies, not every secret throughout its lifetime:
- Static and still-live library buffers can retain bytes; heap hooks do not intercept in-place compaction.
- Packet-suffix wiping is deliberately prefix-preserving and is not an asynchronous-auth wipe guarantee.
- Backend-specific spills, stack/register copies, crypto intermediates and accelerator state were not exhaustively audited.
- Browser memory, flash history and all allocator regions are not proven clean; do not export raw memory dumps as evidence.
## Existing cleanup verified, not presumed broken
Inspection of the installed original sources found existing wipes on the checked normal paths:
- wolfSSL `wolfcrypt/src/ecc.c:wc_ecc_free()` calls `mp_forcezero()` for the private scalar;
`integer.c` wipes used digits before release, while `tfm.c` delegates to `fp_forcezero()`.
- mbedTLS `library/pk_wrap.c:eckey_free_wrap()` delegates to `ecp.c:mbedtls_ecp_keypair_free()`;
the private MPI reaches `bignum.c:mbedtls_mpi_free()` and its zeroize-and-free path.
- mbedTLS `library/md.c:mbedtls_md_free()` zeroizes/frees HMAC pads and wipes the context.
- mbedTLS `library/ssl_tls.c:mbedtls_ssl_free()` zeroizes/frees input/output record buffers;
its inspected buffer-resize path also zeroizes retired storage.
Thus ordinary destructors are **not generally broken across both stacks**. The confirmed gaps above
are separate raw-copy, ownership, resize and packet-lifetime issues. IDF dynamic TLS buffers are
compile-rejected because their destruction bypasses the inspected upstream record-buffer path;
other configurations/backends need their own review, not extrapolation from these observations.
## Current protocol allowlists and compatibility
| Layer / setting | Exact current policy |
|---|---|
| HTTPS versions | TLS 1.2 only; renegotiation disabled or compiled out |
| HTTPS suites | `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`, `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` |
| SSH `Kex` | `curve25519-sha256,ecdh-sha2-nistp256` |
| SSH `Key` (host identity) | `ecdsa-sha2-nistp256` |
| SSH `Cipher` (both directions) | `aes128-gcm@openssh.com,aes256-gcm@openssh.com` |
| SSH `Mac` (both advertised directions) | `hmac-sha2-256`; GCM provides the negotiated AEAD integrity |
| SSH `KeyAccepted` | `ssh-ed25519,ecdsa-sha2-nistp256` (`server-sig-algs` advertisement only) |
| SSH compression | `none` |
| SSH authentication | Password or enrolled Ed25519/ECDSA-P256 public key; keyboard-interactive rejected |
SSH list strings have static lifetime because contexts/sessions borrow their pointers. List setters
alone do not validate compiled support; the source/production-feature tests check names, IDs and
serialized initial/rekey lists. Enrollment/authorization remains in the user database, not `KeyAccepted`.
Legacy CBC/CTR-only SSH clients, excluded KEX/host-key clients, and CBC-only TLS clients cannot connect;
TLS clients need TLS 1.2 plus one listed ECDHE-ECDSA GCM suite (TLS-1.3-only also fails).
There is no automatic compatibility fallback. Modern-client compatibility is still a live-test gate,
not a claim that signature verification, real KEX/rekey or TLS/SSH handshakes were exercised here.
## Web admission and retained credential/browser policy
The early quota probe neither consumes attempts nor advances the window. The final post-parse
reservation preserves **five password verifications per 60 seconds globally**; malformed requests
are not charged. Exhausted requests avoid body receive/parser/KDF and close without draining unread
bodies. Challenges remain consumable before this probe: this does **not** establish challenge fairness
or prevent global starvation. HTTPS service stop/start resets this window/challenges, unlike SSH's
boot-lifetime admission buckets. Epoch/readiness checks fence stale work at both quota boundaries.
Raw JSON is wiped after parsing and before KDF; parsed credentials immediately after authentication;
denial paths wipe both before error responses. Ordinary final request/token cleanup remains in place.
`src/user_database.{c,h}` remains unchanged: **1264 printable ASCII bytes** (`0x20``0x7e`),
PBKDF2-HMAC-SHA256 with **50,000 iterations**, **16-byte random salt**, **32-byte verifier**.
Generated passwords select **24 symbols from 64**, giving **144 bits** with uniform secure randomness.
This is a reviewed retained baseline, not a claim that 50,000 iterations meets every current deployment
recommendation. Benchmark target verification latency and mixed-load headroom before choosing a new
cost; do not blindly increase it. No verifier storage format or key-rotation behavior changes here.
`src/web_security.c` generates a self-signed **P-256 / ECDSA-SHA256** certificate, non-CA,
digital-signature usage, server-auth EKU, device DNS and fixed AP IPv4 SANs, with fixed validity
**2025-01-01 through 2049-12-31**. Existing validation checks the key pair, expected fields/SANs and
self-signature; this inspection is not a new real-crypto signature-verification test.
Compare the certificate SHA-256 fingerprint through trusted UART0 (`web certificate info`) before
accepting browser trust; a warning bypass is not verification, nor is arbitrary STA-IP trust solved.
Existing persistence/rotation/recovery contracts remain unchanged; NVS is not newly encrypted.
`src/web_login_ui.c`, `src/web_ui.c` and `src/web_cookie_auth.c` retain CSP, document/auth
`Cache-Control: no-store`, and `Secure; HttpOnly; SameSite=Strict` cookies. Static assets retain their
separate caching policy. HSTS is deliberately not blindly forced for the self-signed hostname/IP
workflow: it is not a substitute for verified certificate trust and may obstruct recovery.
## Maintenance and evidence
1. Keep the original SDK/managed sources untouched. Maintain reviewed `Entry` hashes and exact-once
edits in `tools/security_overrides.py`; never repin a hash merely to make configuration succeed.
2. Re-audit changed source ownership, cleanup, allocator extents, algorithms and resolved features.
Full original SHA-256/version mismatch, missing/ambiguous edits or source registration fail closed.
3. CMake retains component targets and source properties/quoted-include context, replacing exactly one
original compilation per entry. Generator/version/original/derived changes trigger reconfiguration;
changed originals fail the hash check. Do not hand-patch SDK files or derived build-tree output.
4. Derived full files preserve original copyright/license notices; regenerate through configuration,
verify exact generated bytes and single-source registration, then rerun the relevant host contracts.
5. Future release/dependency review remains pending: external advisories and license obligations have
**not** been reviewed here. No CVE absence, vulnerability completeness or license-compliance claim.
Verified host commands passed during this documentation audit (prefix `CCACHE_DISABLE=1`):
- `python3 tests/sdk_security_overrides/run.py --build-dir .pio/build/esp32-s3-devkitc-1-n16r8`
- `python3 tests/ssh_memory/run.py --idf-path /home/mscholz/.platformio/packages/framework-espidf`
- `python3 tests/ssh_protocol_policy/run.py`
- `python3 tests/wolfssh_auth_contract/run.py`
- `python3 tests/web_cookie_auth/run.py`
These execute actual modules/extracted installed or patched functions with heap, crypto, IO and layout
mocks, plus pinned source/production-feature contracts and existing Ninja registration checks.
They cover cleanup failures, null-first-read behavior, policy serialization/publication and web quota/wipe
ordering; they are not complete parser fuzzing, real signature verification, live handshakes or target tests.
No firmware build, upload, erase, raw-dump export or hardware operation was performed for this document.
+117
View File
@@ -0,0 +1,117 @@
# Security operations — Phase 9D
Operator runbook for the current firmware; procedures below are **not execution records**.
Use the [command reference](command_reference.md) for complete syntax and the [browser contracts](web_administration.md) for frontend permissions and result handling.
No device operation is authorized merely by this document. Schedule disruptive work, retain trusted UART0 access, and obtain explicit approval for destructive recovery/erase.
The user will validate **whole Phase 9**, not provide per-segment sign-off. **9D is in progress; Phase 9 is not complete or production-ready.** The [partial wolfSSL/wolfSSH advisory review](wolf_security_review.md) leaves current-path security findings open; the [bounded license inventory](dependency_licenses.md) leaves distribution/corresponding-source/notice questions unresolved. This runbook neither resolves those findings nor claims device validation or license clearance.
## Safety and access
- Restrict physical access: UART0 is the trusted administrative recovery authority. Use the USB-to-UART bridge at 115200 baud, not native USB CDC.
- Native USB is network-independent, binary-transparent UART1 access through the one-writer/multiple-observer broker; it is not an admin console. Reboot interrupts every transport.
- Use a trusted workstation and restricted provisioning network. Disable terminal recording, screen sharing and clipboard history before secret entry/reveal; do not put secrets in command arguments, tickets, screenshots or routine logs.
- UART0 and authenticated role-`admin` SSH provide the operational commands below, except `user recover --force` is UART0-only. Role-`user` SSH is serial, not administration.
- Browser shell is narrower: other-account interactive add/password and forced role/delete only; no self/generated/key/recovery account commands. Wi-Fi/mDNS shell access is status-only. Typed Settings has separate account/network/identity workflows; do not infer shell parity.
- NVS is **unencrypted**: Wi-Fi PSKs and HTTPS/SSH private keys are plaintext at rest; password verifiers are also sensitive. Secure boot and NVS encryption are not provided by this phase. Physical flash extraction and firmware replacement remain outside the protection boundary.
## First provisioning — UART0 first
1. Start in a controlled RF environment with UART0 attached. Defaults enable Wi-Fi with a random-password fallback AP and no station profiles; networking can start before the console is ready. A new AP name is not an authentication secret.
2. Once the console is ready, use `wifi stop`, inspect `wifi status`, then `wifi save` if Wi-Fi must remain disabled after reboot. Stop is asynchronous: check actual state, not only the queue acknowledgement. Do not rely on this to prevent the initial boot's radio exposure.
3. Inspect `user status`. Missing user storage initializes empty; create the first administrator with `user add <username> admin` on UART0 and the hidden confirmation prompts. There is no bootstrap/shared default web credential. A healthy empty database needs **add**, not recovery.
4. Store the chosen password in the operator's protected vault. Usernames and password bounds are in the command reference (passwords: 1264 printable ASCII characters). Optionally use `--generate` only with private, reliable one-time capture. Create/test a separately controlled recovery administrator if policy requires one; the final administrator cannot be deleted or demoted.
5. Choose AP policy explicitly with `wifi ap policy off` or `wifi ap policy fallback` (use `always` only intentionally). For retained AP access, set `wifi ap secret` through the hidden 863-character prompt, or privately retrieve the generated password with `wifi ap show-secret`. Never include its output in routine captures.
6. Configure an unused/disabled station slot using `wifi profile set <slot> <priority> <mixed|wpa3> <ssid>`, then `wifi profile secret <slot>`, then `wifi profile enable <slot>`. Check peer compatibility before choosing WPA3. Profile set preserves the existing secret/enabled state; disable an existing slot before staging its replacement. Enabling or editing an enabled profile/AP can reconnect the radio.
7. Use `wifi start` when ready, inspect `wifi status` / `wifi profiles`, and `wifi save` to persist the intended working policy, profiles, secrets and boot-enabled state. Restrict upstream network access to intended operators; do not expose ports 443/22 to untrusted networks.
8. Read `web certificate info` and `ssh host-key info` through trusted UART0. Compare the certificate SHA-256 fingerprint in the HTTPS client and the OpenSSH `SHA256:` host-key fingerprint **before supplying account credentials or accepting changed trust**. A self-signed certificate or an unsolicited network fingerprint alone is not proof of device identity.
9. Establish narrowly scoped browser certificate trust and SSH known-host trust only after comparison. Investigate an unexpected mismatch rather than disabling verification or deleting all known-host entries. Test fresh HTTPS login and both intended SSH roles; an admin SSH login does not acquire a serial writer lease.
10. Set required serial framing, mDNS suffix and display policy with their documented commands and explicit saves. Record intended state privately; later rehearse a reboot and compare actual loaded settings/trust before production use.
**Fallback-default caution:** true first boot attempts to persist generated Wi-Fi defaults, but that save can fail. Incompatible stored configuration selects generated RAM defaults without overwriting the rejected blob. `wifi defaults` generates fresh RAM defaults; `wifi reset` generates and saves them. Both restore enabled/fallback policy and replace the AP password, not merely repair connectivity. Re-protect the AP and verify/save intended state rather than assuming an old password or disabled radio survived.
## Working configuration versus service lifecycle
| Operation/domain | Persistence and operational effect |
|---|---|
| Serial, Wi-Fi, mDNS, display edits | Working RAM until `serial save`, `wifi save`, `mdns save` or `display save`, respectively; `load` reads that domain's storage, not an external backup. `defaults` is RAM; `reset` applies/persists domain defaults. Check errors and actual state; these domains do not share one atomic transaction. |
| `wifi start` / `wifi stop` | Queue runtime action **and** set RAM `enabled-at-boot` true/false; only `wifi save` persists it. Local start/stop has the same semantics. |
| `web start` / `web stop`, `ssh start` / `ssh stop` | Runtime service controls, not persistent enable/disable configuration. No corresponding service `save` or durable boot-disable command. Stops disconnect that service's clients. |
| Boot network services | `main.c` attempts HTTPS/SSH startup independently when Wi-Fi initialization/start has no error and each service's security/runtime initialization succeeds. Wi-Fi configured off can still initialize successfully: listeners may be started without radio reachability. Wi-Fi-off is not a persistent HTTPS/SSH-disable flag. |
| Accounts and identities | Mutations commit their own NVS blob as part of the operation; no follow-up `save`. Commit acknowledgement is not a general power-loss/durability guarantee. |
| `reboot` | Loses unsaved RAM and all live sessions. A runtime service stop does not survive normal boot startup. |
## Routine account, password and authorized-key rotation
1. Keep UART0 or a separate current administrator available. Inspect `user show <username>` privately to confirm role and current key slots; do not operate from stale indices. The database allows eight accounts and three authorized keys per account.
2. Set a known replacement with `user password <username>` using hidden prompts. `user password <username> --generate` commits then displays a value once: protect it immediately. Lost generated output has no retrieval command; inspect account state, then deliberately set another known password via UART0/another admin rather than blindly replaying generation.
3. Admin SSH **rejects its own generated-password replacement**; its ordinary hidden self-password change is allowed but revokes the invoking principal. Browser shell denies all self account mutations. Typed Accounts supports self changes; its Generate action only returns a value, does not change the account, and has no retained retrieval. Securely save/acknowledge it before separately submitting the mutation.
4. For an authorized-key rotation, generate/protect the private key on the operator's host; import only the supported Ed25519 or ECDSA-P256 **public** key with `user key add <username>`. With capacity available, add first, test a fresh login using the new key, then remove the old key using its freshly inspected slot: `user key delete <username> <0..2> --force`. If full, use independent recovery access before removing a slot. `user key clear <username> --force` removes all that account's keys, not its password.
5. Password, role and key changes stale the affected principal; deletion removes the account. Committed changes trigger targeted web-ticket/session and SSH revocation, supplemented by authoritative currentness checks. Self changes can lose their result before it arrives; unrelated accounts should remain usable. Disconnect/timeout is not proof of rollback or cancellation of admitted work.
6. Reauthenticate to verify the new credential and rejection of the retired one; do not confuse admission throttling with a bad password. For account retirement use `user delete <username> --force`; for reduced privilege use `user role <username> user --force`. Preserve a usable administrator. A password change does **not** remove authorized keys, and key removal does **not** change the password: revoke both paths for a compromised account.
7. Update the protected operator record and revoke any reused credentials/keys on other systems separately. A key can authorize multiple device accounts; remove every affected authorization. Retire superseded host-side private-key copies according to storage policy.
## HTTPS and SSH server identity rotation
Plan a maintenance window and distribute newly verified public trust through a trusted channel. Server identity changes are separate from account passwords/authorized keys and do not replace them.
- **HTTPS:** `web certificate rotate --force` replaces certificate **and** private key. Owner ordering is reserve → generate/commit/publish → stop/restart if running. Precommit failure leaves the old identity/logins in place; after commit, lifecycle failure never rolls the identity back. Failed stop can leave the old certificate served while UART0 reports new stored material. Rotation while stopped stays stopped.
- **SSH:** `ssh host-key rotate --force` reserves ownership → stops sessions → generates/commits/publishes → restarts if previously running. Failed stop skips mutation/start. Persistence failure may follow client disconnection and attempts to restart with unchanged material; that restart can also fail. A committed new key is never rolled back after restart failure. Rotation while stopped stays stopped.
- UART0/admin SSH expose both rotations. Browser shell allows only exact `web certificate rotate --force`, not SSH host-key mutation; typed HTTPS/SSH Settings provides its own bounded rotation flow. Neither browser route provides identity reset/recovery/export.
- Remote scheduling/drain/HTTP acknowledgement is not peer receipt or operation completion. On timeout/lost result, use Check Result/Refresh where available, then inspect UART0 service status and fingerprints. **Do not automatically repeat rotation.**
- Resolve lifecycle failures with deliberate UART0 `web stop` / `web start` or `ssh stop` / `ssh start`, checking each outcome; do not start over failed cleanup or rotate again to repair it. If recovery needs reboot, account for unsaved state and all-transport interruption.
- After recovery, compare the actually served identity against trusted UART0, replace only the relevant client trust entry, and test a fresh connection. HTTPS restart requires fresh login; SSH rotation closes SSH sessions but does not inherently require HTTPS relogin. USB/UART0 remain independent of these network-service rotations, not of reboot.
## Configuration backup and reconstruction
There is **no implemented configuration export/import or full backup/restore workflow**, nor a supported private-identity export/restore command. `save` writes working state to the same device; it is not an off-device backup. Do not use raw NVS/flash dumps as routine backups or duplicate them onto replacement devices.
Maintain an access-controlled operator inventory outside the repository: reviewed firmware/build provenance, intended serial settings, station/AP policy and profile ordering, boot-enabled intent, mDNS/display settings, account roles, authorized-public-key provenance, verified public server fingerprints, and last successful save/rehearsal outcomes. Keep device/account/network identifiers in that protected record, not public issue logs. This runbook intentionally contains no populated inventory or identifier template.
Keep passwords, Wi-Fi PSKs and client private keys in a separately protected, backed-up vault with controlled recovery access and retention. Inventory entries should reference vault records rather than duplicate secrets. Ordinary status cannot reconstruct PSKs/passwords/private keys; public fingerprints cannot recreate an identity. Do not collect verifier material, session cookies or tickets for backup.
Reconstruction means first-admin provisioning, manually re-entering reviewed settings and secrets, re-importing authorized **public** keys, explicitly saving each configuration domain, and verifying new server trust. Replacement hardware/newly generated identities require client trust changes, not promises to restore the old identity. Test the reconstruction on an isolated spare with synthetic credentials. Exceptional forensic flash capture requires separate approval, restricted handling and retention; it remains secret-bearing, not a supported restore format.
## Recovery — smallest affected domain first
Use UART0 status/error observations to distinguish connectivity, authentication, storage and lifecycle faults. Avoid reset for ordinary login throttling or protocol mismatch; restrict hostile traffic and allow natural budget refill. Native USB preserves serial access during network failure, not administrative recovery.
| Fault | Deliberate recovery and scope |
|---|---|
| Wrong/lost password, healthy database | `user password <username>` through UART0 or another current admin; inspect/revoke authorized keys separately if compromised. Do not recover/erase the database. |
| User database unavailable after failed initialization | Only with explicit approval, UART0 `user recover --force` replaces **only the user blob** with an empty database, discarding all accounts/verifiers/authorized keys. Then `user add <username> admin`. Healthy databases, including healthy empty ones, are refused. Underlying NVS/RNG/allocation failure can still prevent recovery. Empty storage commits before dummy-verifier initialization; a later initialization error does not prove the old accounts survived. |
| HTTPS material unavailable/damaged | With approval, UART0 `web reset --force` replaces **only HTTPS certificate/private key**, not users or SSH/Wi-Fi configuration; it can also replace healthy material. Unlike stopped rotation, reset attempts to start a stopped service. Verify new trust and lifecycle state. |
| SSH material unavailable/damaged | With approval, UART0 `ssh reset --force` replaces **only SSH host identity**, not account keys/passwords or HTTPS material; it can also replace healthy material and attempts startup. Verify new trust and lifecycle state. |
| Ordinary configuration wrong/incompatible | Review the domain's `status`, then choose `load`, manual correction plus `save`, or explicitly approved domain `reset`. `serial reset`, `wifi reset`, `mdns reset`, `display reset` are not factory resets; Wi-Fi reset can reopen fallback access with a new password. |
| Service/network fault with healthy identity | Recover radio/profile or stop/start the affected service; preserve identities. If initialization remains unavailable, investigate the reported dependency/storage fault before an approved reboot or broader recovery. |
Identity resets are also available to authenticated admin SSH, but UART0 is preferred for recovery; browser reset is unavailable. None of these domain commands promises secure deletion of superseded flash bytes. A failed write or lost result calls for inspection, not assumptions about persisted state.
## Destructive factory-style reset — explicit approval only
There is **no on-device factory-reset command**. Full reset is the host-side erase/reflash procedure, not a collection of identity resets. It is not a routine upgrade, backup, password repair or prerequisite for this runbook.
1. Obtain explicit approval for this particular target and **all flash/NVS data loss**; confirm the physical device/port, maintenance window, protected reconstruction inventory and reviewed firmware. Disconnect sensitive serial equipment and isolate RF before erase/reflash.
2. Only in that separately authorized hardware session, from the repository root, run `pio run --target erase`; check success, then reflash with `pio run --target upload`. Use `pio device monitor -b 115200` on the USB-to-UART bridge for provisioning. Do not automate or run these operations as documentation validation.
3. Erase removes firmware and all NVS domains, including users, Wi-Fi secrets/configuration, TLS/SSH identities, serial, mDNS and display settings, plus other flash contents. Ordinary upload alone is **not** a reset. Reflash and follow UART0-first provisioning; expect newly generated identities/AP secret and no old accounts. Re-establish client trust only after verification.
4. Erase success is **not a forensic secure-erase guarantee**, proof that external copies disappeared, or a general persistence guarantee. Preserve custody/disposal controls below. See the [documented partition migration](../README.md#one-time-migration-from-the-default-partition-table) before changing layouts.
## Decommissioning
- Isolate/disconnect the device and attached serial equipment; revoke access **outside the device** before custody changes. Rotate upstream/shared Wi-Fi PSKs or revoke network admission as applicable; remove saved device-AP credentials on peers.
- Remove retired device certificate exceptions/pins and SSH known-host trust on operator systems. Revoke account/key authorizations and reused credentials on other devices/services separately; a local reset cannot revoke remote trust or a copied private key.
- If approved, perform the destructive erase above; do not reflash merely to retire hardware. Logical rotation/reset can leave historical plaintext NVS pages, older credentials and private keys in flash. Retired flash/RAM captures, backups, terminal records, vault versions and workstation copies require separate retention/disposal handling.
- Flash erase, file deletion and software RAM wiping do not prove all copies are unrecoverable. Maintain physical custody; use approved media destruction/device disposal when confidentiality requires it. Do not claim secure boot, encrypted NVS, extraction resistance or guaranteed erasure.
## Deferred whole-Phase-9 hardware rehearsal
Use a separately authorized, isolated expendable target and synthetic secrets. Record sanitized outcomes, exact tested build and limitations, not passwords, raw dumps or live identifiers. These checks are **planned, not passed**, and join the [combined Phase 9 gates](security_hardening.md#combined-phase-9-target-validation--deferred-not-run) and [administration regressions](user_administration_tests.md):
- [ ] First boot/healthy-empty provisioning; no remote first-admin path; fallback AP protection, saved radio-off/on behavior, and fresh trusted HTTPS/SSH login for both roles.
- [ ] Save/load/default/reset/reboot per configuration domain; failed first-default save and incompatible Wi-Fi storage; compare runtime radio/listener state with saved boot intent.
- [ ] Password/key/role/delete rotation: old credential rejection, self-revocation, unrelated-account isolation, final-admin protection, remote self-generated denial, typed generation-before-submit, lost/partial one-time output and stale results.
- [ ] Both identity rotations running/stopped; failed stop/commit/restart and lost ACK/result using reviewed fault injection; no blind replay, stored-versus-served trust comparison, reconnect/relogin and independent UART0/USB access.
- [ ] Each domain recovery preserves unrelated domains; unavailable-user recovery is UART0-only, healthy recovery refused. Reconstruct a spare from protected records without raw NVS restore.
- [ ] Separately approved destructive erase/reflash rehearsal: all domains reset, fresh identities/AP credential/accounts provisioning; rehearse external trust revocation and disposal checklist without claiming secure erasure.
- [ ] Complete existing whole-phase crash/no-dump, authentication/admission/refill, hidden-input, protocol/rekey, allocation-failure and full transport-mix/resource checks; verify one writer, isolated observers and recovery. Do not substitute this runbook review for those measurements.
Source basis: [startup](../src/main.c), [console policy](../src/admin_ssh_console.c), [users](../src/user_console.c) / [storage](../src/user_database.c), [Wi-Fi commands](../src/wifi_console.c) / [manager](../src/wifi_manager.c) / [storage](../src/wifi_config.c), [HTTPS owner](../src/web_server.c) / [material](../src/web_security.c), and [SSH owner](../src/ssh_transport.c) / [material](../src/ssh_security.c). Commit ordering is an implementation contract, not power-loss or hardware-validation evidence.
+112
View File
@@ -0,0 +1,112 @@
# SSH key-validation review — 2026-09-15
## Decision and scope
**The baseline P-256 ECDH and X25519 validation gaps are mitigated by project-owned compile policy. Parent firmware build and local strict crypto suite PASS; the broader SSH review and hardware gates remain open.** This supplements, rather than silently rewrites, the historical [wolf review](wolf_security_review.md).
- Enable upstream `WOLFSSL_VALIDATE_ECC_IMPORT` for wolfSSL 5.8.2. The SSH server imports an unauthenticated P-256 peer point and otherwise reaches scalar multiplication without an on-curve check.
- Enable existing upstream `WOLFSSL_ECDHX_SHARED_NOT_ZERO`. The SSH X25519 input precheck does not reject every low-order input; the result check was disabled.
- These are PUBLIC definitions in `cmake/wolf_crypto_policy.cmake`, with resolved-settings checks in `cmake/wolf_crypto_policy.h`. No root `CMakeLists.txt` edit is necessary: it already includes this module after `project()`. The existing small X25519/Ed25519 policy and RNG/acceleration controls are preserved.
- No dependency version, installed vendor source, generated override, application source, or device change was made by this task. Edits are restricted to the assigned policy files, `tests/wolf_crypto_policy/`, and this report.
- This is **not** a demonstrated long-term-key recovery, authentication bypass, remotely measured exploit, full upstream backport, or release approval. ECDH uses a freshly generated ephemeral key, separate from the long-term signing identity.
## Exact local evidence
Inspected installed wolfSSH 1.4.20 / wolfSSL 5.8.2~1 and the generated wolfSSH input located through `.pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json`. SHA-256 snapshot:
| Input | SHA-256 |
| --- | --- |
| `managed_components/wolfssl__wolfssh/src/internal.c` | `81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9` |
| `.pio/build/esp32-s3-devkitc-1-n16r8/security_overrides/wolfssh_internal/internal.c` | `9e5923d536cee049409a7df471d155a6dd2e804d55a1528f5c4abc8e34807dea` |
| `managed_components/wolfssl__wolfssl/wolfcrypt/src/ecc.c` | `909c57e2756a8002df9f1d214483c659cb20db4a5f51047eda62d64ac458db06` |
| `managed_components/wolfssl__wolfssl/wolfcrypt/src/curve25519.c` | `9a0f6f0205245a8d19500a936d9b02bb71c8656713648408d1cb408362694b76` |
| `managed_components/wolfssl__wolfssl/wolfcrypt/src/signature.c` | `62ab3db3dfd251b2a2c73b69ef05aab6085d2e0d673fd9159514b3ee261cea4f` |
The original and generated bodies of `HashForId`, `KeyAgreeEcdh_server`, `KeyAgreeCurve25519_server`, `SignHEcdsa`, and `DoUserAuthRequestPublicKey` remain byte-identical. `DoUserAuthRequestEcc` and `DoUserAuthRequestEd25519` now contain the reviewed label/framing corrections from `tools/security_overrides.py`. The crypto suite independently reconstructs only those exact deltas from the hash-pinned original, requires exact anchor counts, and compares all seven complete functions with the actual generated compilation input. It does not derive its expected bodies from the generator being checked, skip changed functions, normalize away changes or relax the original hashes. Any additional change requires re-review. This is not a whole-generator equivalence test.
Generated-source line references below retain the initial pre-parser-correction snapshot; use the named functions in the current generated source, whose hash is recorded above.
Before changing policy, actual Xtensa preprocessing of `ecc.c` confirmed `USE_FAST_MATH`, `ECC_TIMING_RESISTANT`, `WOLFSSL_SMALL_STACK`, `HAVE_ECC_CHECK_KEY`, and internal `HAVE_ECC_CHECK_PUBKEY_ORDER`; neither validation definition was present. `USE_ECC_B_PARAM` and SP math were absent. Initial candidate replay checked the proposed definitions. Following the parent build, **strict mode now passes without injecting policy flags**, checking the saved production compiler/includes/definitions and syntax for twelve library/SSH/application translation units, including `ecc.c` and `signature.c`. Parent-reported `pio run` PASS: **94,340 B linked RAM / 1,768,949 B flash**. This agent did not rerun PlatformIO or inspect a flashed image; compile-profile checks and the supplied link/build result are distinct evidence.
## P-256 import and ECDH
Generated `internal.c:1125111317`, `KeyAgreeEcdh_server`:
1. Derive `primeId` from negotiated KEX; project policy allows `ecdh-sha2-nistp256` and X25519.
2. Initialize public/private keys; attach the session RNG to the ephemeral private key.
3. `wc_ecc_import_x963_ex(handshake->e, eSz, pubKey, primeId)` imports the peer point.
4. Only after successful import, generate a fresh private key, export its public point, and call `wc_ecc_shared_secret`.
5. Errors propagate; both key objects are freed. The policy adds no task, queue, retry loop, protocol control sequence, or new entropy source.
Installed `ecc.c:1070911020` parses X9.63 coordinates and selects the curve. At `1099310996`, `wc_ecc_check_key` is called **only** with `WOLFSSL_VALIDATE_ECC_IMPORT`. Merely compiling `HAVE_ECC_CHECK_KEY` is not equivalent. The inferred-curve `wc_ecc_import_x963` wrapper uses the same implementation.
Without this flag, `wc_ecc_shared_secret` (`46694752`) checks pointers, private-key type, domain metadata and matching curve IDs, not whether the peer coordinates satisfy the curve equation. Its software path reaches `wc_ecc_shared_secret_ex` (`5102`), `wc_ecc_shared_secret_gen_sync` (`4759`), and `wc_ecc_mulmod_ex2` (`4942` vicinity), using curve A, prime and order. No equivalent point-validation call precedes the multiplication. **The absent import validation is applicable, not just an unproven macro concern.**
With the flag, `_ecc_validate_public_key` (`1049510703`) checks infinity, coordinate ranges, the curve equation and public-point order; private imports additionally check the private range and private/public consistency where applicable. It loads B from `key->dp->Bf` even when `USE_ECC_B_PARAM` is absent. There is no need to force B storage or reproduce the larger upstream source refactor. The guard rejects configurations that disable the software validator or route it to the known successful hardware stubs; it does not claim arbitrary future backends are validated.
The work is bounded by the selected curve and existing key/input limits, but **not free**: valid imports incur public validation, including order checking, and private/public consistency checks can add multiplication and allocations. Target latency, memory peaks, stack margins, repeated-handshake/rekey load and the existing deadlines must be measured. Existing admission limiting is not evidence that this cost is harmless.
## X25519 all-zero result
Generated `internal.c:1133511394`, `KeyAgreeCurve25519_server`, explicitly runs `wc_curve25519_check_public` before import, key generation and shared-secret calculation. Installed `curve25519.c:645710` rejects wrong lengths, zero/one, the high bit, and the upper-end range in the little-endian path. This is **not** a complete low-order rejection rule.
The real vendor small-math tests exercise two nontrivial low-order u-coordinates that pass that precheck:
- `e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800`
- `5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157`
`wc_curve25519_shared_secret_ex` (`452536`) already contains a 32-byte OR reduction under `WOLFSSL_ECDHX_SHARED_NOT_ZERO`. It returns `ECC_OUT_OF_RANGE_E` for an all-zero result before copying it to the caller, then wipes its temporary. Neither the current SSH server helper nor subsequent successful-KEX processing adds an equivalent result test. Enabling this existing check is sufficient for the inspected software path. Tests verify rejection of both inputs, unchanged caller output on rejection, ordinary zero/one precheck rejection, and the valid RFC7748 shared secret. No custom blacklist or small-math/blinding combination is introduced.
## Raw signatures and CVE-2026-5194 applicability
**No attacker-selected short digest/OID-confusion trigger was found in the reviewed current raw SSH authentication/signing paths. The separate ECC/Ed25519 label and signature-framing defects have now been corrected by the parser owner, as reviewed below; that does not backport generic crypto API hardening.**
- `src/ssh_transport.c:489` calls `user_database_authorize_ssh_public_key` before wolfSSH signature verification. `src/user_database.c:185` vicinity checks exact embedded type, exact `nistp256`, 65-byte uncompressed point and end-of-blob; mbedTLS parses and checks the point. `user_database_authorize_ssh_public_key:810` repeats validation and requires exact stored key-type/blob matching for the named account. Thus the server user-key path already has an independent P-256 point-validation boundary, unlike unauthenticated KEX. The new wolfSSL import check is defense in depth here.
- Generated `DoUserAuthRequestPublicKey:7324` vicinity derives `hashId = HashForId(pkTypeId)`, obtains `digestSz` from `wc_HashGetDigestSize`, and hashes the session ID and authentication message locally. `HashForId` maps P256 to SHA256: the digest is 32 bytes, not a peer-supplied digest length. The untrusted signature type does not choose an alternate prehash independently of that authorized key type.
- Generated `DoUserAuthRequestEcc:6851` vicinity imports Q, converts raw r/s through `wc_ecc_rs_raw_to_sig`, and calls `wc_SignatureVerifyHash` with that locally computed digest and length. Installed `signature.c:131` only checks that the hash type exists, not equality of `hash_len` with its size; `ecc.c:9204` checks r/s ranges but predates the new minimum-digest check. These upstream API weaknesses remain in the dependency, but the reviewed caller supplies the correct size.
- `SignHEcdsa:11676` hashes exchange hash H using `HashForId(handshake->pubKeyId)` and signs the resulting full SHA256 digest for the allowed P256 host key. This is distinct from ECDH.
- Client host verification (`DoKexDhReply`, generated calls near `5651/5685`) uses `wc_SignatureVerify`, which computes the full digest before verification. Client `BuildUserAuthRequestEcc` derives its digest size from `HashForId(keySigId)`. These are not the intended application's server authentication role. Existing message-order concerns mean role alone must not substitute for validating dispatch reachability.
- Certificate variants (`DoUserAuthRequestEccCert`, `BuildUserAuthRequestEccCert`) are under disabled `WOLFSSH_CERTS`. Current Ed25519 auth takes its separate message/streaming-verification path, not an attacker-sized Ed25519ph digest. Ed448 and ML-DSA are not current SSH algorithms.
- Host private DER decoding goes through `wc_EccPrivateKeyDecode` in installed `asn.c:35833`; template parsing calls `wc_ecc_import_private_key_ex` near `36033`. Real host tests cover valid SEC1 P256 private/public import without a pre-attached RNG, plus rejection of a corrupted embedded public point. This is not an NVS lifecycle or identity-rotation test.
The compile-policy mitigation does **not** backport PR10131's global digest-length/OID enforcement. Reassess if new raw APIs, certificates, key types, callbacks or client roles are enabled.
## Parser-owner corrections reviewed and remaining work
The following formerly pending gaps are **fixed in the current generated input**, not by the crypto compile flags:
1. Both key/signature label checks in `DoUserAuthRequestEcc` and `DoUserAuthRequestEd25519` now use OR. Unequal lengths reject before `memcmp`; equal lengths compare the bounded expected span. This rejects equal-length wrong labels and avoids comparing an oversized label against the shorter expected label. Existing error normalization remains unchanged.
2. ECC `GetSize` first proves `sz <= signatureSz - i`; converting `sz` to the absolute end with `sz += i` therefore cannot wrap. Both `GetStringRef` calls use that end, not the outer field size. The subsequent `i != sz || sz != pk->signatureSz` rejection requires exact inner and outer consumption before conversion or verification.
3. Ed25519 requires `sz == pk->signatureSz - i` before starting streaming signature verification. Trailing bytes outside the declared signature string now reject.
Reviewed `tools/security_overrides.py` and `tests/wolfssh_parser_contract/{run.py,README.md,auth_framing.c}` against exact original/generated function diffs. These deltas leave ECC digest creation, raw-to-DER conversion and crypto calls, and Ed25519 streamed-message construction unchanged. Valid framing is retained; previously tolerated malformed labels/trailing bytes reject. No exploit or authentication-bypass demonstration is claimed.
The parser suite uses extracted generated functions, guard pages, instrumented nested reads, UBSan trap mode and **crypto doubles**. Its documented 3,124 cases per stack mode and six guard-removal mutations concern parser gating, not actual signature arithmetic. In contrast, this crypto suite executes real installed vendor arithmetic/ASN/wrappers and separately checks exact generated parser deltas and production compilation settings. Neither suite is an end-to-end SSH handshake test.
Parent build plus local strict crypto validation resolve the earlier build/profile handoff; no root edit is requested. Remaining work: broader ordering/state-machine and deferred parser/advisory review, standalone ECC curve-name/key-blob semantic validation if that dependency path is used without the application's existing checks, generic PR10131 API hardening as applicability requires, and whole-phase hardware/resource/latency tests. Later changes to audited functions still require explicit delta review, not silent repinning.
## Tests and limitations
Commands run for this task:
```sh
python3 tests/wolf_crypto_policy/run.py --host-only
python3 tests/wolf_crypto_policy/run.py --candidate
# Follow-up after parent firmware build:
python3 tests/wolf_crypto_policy/run.py
```
**Follow-up strict suite PASS**, including all host vectors and private ASN-decode cases, seven exact source-body checks with independently specified parser deltas, and production flags without candidate injection. Earlier host-only/candidate runs also passed. Parent build evidence is supplied, not rerun here; no device validation was performed. Details are in the companion [test README](../tests/wolf_crypto_policy/README.md). Coverage includes 20 guard cases; a host CMake fixture executing the real module over a library → SSH → app graph; real installed small-X25519/Ed25519 and TFM P256 crypto; explicit/inferred import rejection for off-curve, infinity, out-of-range, truncated and wrong-tag points; valid ECDH; raw/DER valid and invalid ECDSA verification; private-key ASN import; exact source checks; twelve strict production target macro/syntax checks; four real-settings missing-policy rejection cases.
Host settings use software TFM, ECC timing resistance and small-stack allocation, but host word size, allocator, OS entropy, compiler and absent ESP acceleration differ from firmware. The CMake fixture is not the full ESP-IDF graph. No exhaustive Wycheproof/fuzz campaign, allocator-failure injection, crypto-suite sanitizer execution, network handshake, real rekey, timing/side-channel measurement, stack/heap reserve measurement, agent-performed firmware link, flashing or hardware validation is claimed. The parent-reported firmware build/link and size figures above do not establish runtime reserves. Test development exposed host fixture omissions (POSIX declarations, wolfmath linkage, filesystem RNG and ASN settings); those were corrected without modifying vendor sources.
## External sources rechecked
Read-only retrieval on 2026-09-15:
- [wolfSSL PR10133 diff](https://github.com/wolfSSL/wolfssl/pull/10133.diff): removes conditional B/on-curve gating and treats `wc_ecc_import_x963_ex` input as untrusted by default in the later tree. This is not a directly applied patch to 5.8.2.
- [5.9.1 tagged ChangeLog](https://raw.githubusercontent.com/wolfSSL/wolfssl/v5.9.1-stable/ChangeLog.md), Bug Fixes: explicitly recommends `WOLFSSL_VALIDATE_ECC_IMPORT` for users of older versions. This is the basis for the bounded policy choice.
- [wolfSSL PR10374 diff](https://github.com/wolfSSL/wolfssl/pull/10374.diff): makes X25519/X448 all-zero checking opt-out. The existing 5.8.2 opt-in macro enables the inspected equivalent synchronous result check; later nonblocking/TLS changes are not imported.
- [wolfSSL PR10131 diff](https://github.com/wolfSSL/wolfssl/pull/10131.diff): certificate signature-OID/key-type consistency plus raw digest-size hardening; used to distinguish the current SSH caller contract from unpatched generic API behavior.
PR URLs are mutable and are not an archived commit-pinned upstream evidence bundle. Local original source hashes above and the source-contract tests bound the implementation inspected here.
+276
View File
@@ -0,0 +1,276 @@
# Remaining SSH parser review — 2026-09-16
## Completed scope and decision
**This finite review is complete for the current pinned server profile:** PR899
key skips and CHANNEL_FAILURE, PR902 service validation, PR918/919 forwarding
callback applicability, the remaining generic signature-API caller question, and
**exact `DoChannelRequest` name dispatch including application callback gates**.
Three bounded current-path corrections are implemented. This is not a complete
wolfSSH/wolfSSL audit, a full backport of these PRs, firmware validation, or Phase9
sign-off. It supplements the historical/deferred statements in
[wolf review](wolf_security_review.md), [key-validation review](ssh_key_validation_review.md)
and the [parser contract](../tests/wolfssh_parser_contract/README.md).
Only `tools/security_overrides.py`, `tests/wolfssh_parser_contract/*`, and this report
are owned by this slice. No ordering delta, crypto configuration, version, managed
component, application, production generated file, PlatformIO or device change.
Concurrent packaging/IDF-review work is unrelated and left untouched.
| Reviewed item | Current-profile disposition |
| --- | --- |
| PR899 RSA/ECC unchecked key skips | Confirmed in the pinned client parsers, not reachable through current server dispatch. No speculative client patch. |
| PR899 CHANNEL_FAILURE length predicate | Reachable after authentication. Corrected with a bounded exact recipient parser, not just the upstream predicate change. Existing fatal failure policy retained. |
| PR902 | **Service names**, not channel callbacks. Exact `ssh-userauth` required by current server handler. Client accept half unused/unmodified. |
| PR918/919 | Forwarding global/channel callback handling; compiled out with `WOLFSSH_FWD` absent. No forwarding patch. |
| `DoChannelRequest` prefix/NUL name aliases | Closed: all nine name predicates require exact length then exact bytes; branch bodies and unknown-request handling preserved. |
| Generic signature API / PR10131 remaining question | Weak generic API remains, but no attacker-selected short digest/OID path in the inspected current SSH caller set. No crypto/API patch justified for this profile. |
## Exact source and upstream provenance
The authoritative source is wolfSSH **1.4.20 original + existing ordering delta +
existing parser/password edits + the three corrections below**, not installed source
alone. `render_entry` verifies original SHA-256 and exact-once edit anchors. Tests
render into temporary files; production generated inputs are not overwritten.
| Input | SHA-256 |
| --- | --- |
| Original `managed_components/wolfssl__wolfssh/src/internal.c` | `81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9` |
| Reviewed prior original+ordering+parser generated `internal.c` | `4948f8c447670eb54153dd1f3db69e4fa3092f7d7f7ed58a18a8fa05fcd168ca` |
| Fresh generated `internal.c` after this review | `1fdd608d45c4f33da82b0765dc66e1ec2060e78c744bc539906ef1b8a0f783ae` |
| Unchanged `tools/wolfssh_order/delta.json` | `6a81376fe3ffc5f449cde105402963f52d2d78cc844153e869a7e1e0f734fb76` |
| wolfSSL 5.8.2 `wolfcrypt/src/signature.c` | `62ab3db3dfd251b2a2c73b69ef05aab6085d2e0d673fd9159514b3ee261cea4f` |
Fetched official PR patches and independently fetched their commit patches on
2026-09-16; each pair was **byte-identical**. Exact archives and URL/SHA-256 records
are in `tests/wolfssh_parser_contract/pr*.patch` and `provenance.json`. The runner
also pins those commits/hashes/URLs independently; no download occurs during tests
or configuration.
- [PR899 commit d2eeec5e263a4821c90805963eeb0666e99868a6](https://github.com/wolfSSL/wolfssh/commit/d2eeec5e263a4821c90805963eeb0666e99868a6): RSA algorithm skip, ECC curve skip, CHANNEL_FAILURE predicate; Windows file/terminal hunks unused on ESP.
- [PR902 commit ffa646a4b9d47d5d9d6127db140c433c58b1e276](https://github.com/wolfSSL/wolfssh/commit/ffa646a4b9d47d5d9d6127db140c433c58b1e276): service request/accept name checks.
- [PR918 commit fd82a4bcf55935f0801b14bca6be9c71e32ae914](https://github.com/wolfSSL/wolfssh/commit/fd82a4bcf55935f0801b14bca6be9c71e32ae914): global forwarding callback-before-reply, missing-callback rejection and cancel reply framing.
- [PR919 commit 0317c40fc131fab952d291d43c56c7b7ce5f4303](https://github.com/wolfSSL/wolfssh/commit/0317c40fc131fab952d291d43c56c7b7ce5f4303): reject direct-tcpip channel without forwarding callback.
`review.py` independently reverses only the new notice, the two initial handler
changes and nine exact channel-name predicates, then requires the complete prior
generated-source hash. The complete original `DoChannelRequest` is recovered by
reversing just those predicates. Any other source change, including ordering,
client parsing, request branch bodies, password wiping or crypto callers, fails
this fence. Original/version/anchor guards remain;
no silent repin or removal of existing strict build-source checks.
## PR899: distinguish client host keys from server authentication
`ParseRSAPubKey` reads an unchecked algorithm-name length then adds it to its index.
`ParseECCPubKey` does the same for the curve name. An oversized/wrapping length can
invalidate the intended cursor progression. PR899 replaces these skips with
`GetSkip`; it does not establish full curve-name/key-blob semantic validation.
These are real dependency defects, not evidence of current server exploitation.
Exact static call chain: `DoKexDhReply``ParsePubKey``ParseRSAPubKey` or
`ParseECCPubKey`. There is one call to `ParsePubKey`, in `DoKexDhReply`. The current
server user-key verifier instead uses `DoUserAuthRequestPublicKey`
`DoUserAuthRequestEcc`/`DoUserAuthRequestEd25519`; it does not call either PR899 key
parser. Application authorization additionally validates and exactly matches the
stored key blob before successful proof-of-possession verification.
This exclusion is **not just a server-role assertion**:
1. `src/ssh_transport.c` creates only `WOLFSSH_ENDPOINT_SERVER` contexts.
2. Generated `DoPacket` checks `IsMessageAllowed(..., WS_MSG_RECV)` before dispatch.
3. The current two KEX choices set the server expectation to `MSGID_KEXDH_INIT`
(30), then `MSGID_NEWKEYS` (21), never `MSGID_KEXDH_REPLY` (31) or GEX reply (33).
Both initial KEX and rekey use those restrictions. Before peer KEXINIT or with
no nonzero matching expectation, reply messages are rejected as well.
4. Both dispatch routes to `DoKexDhReply` (31 and 33) therefore fail the generated
expectation gate. Disabled DH/GEX must not be confused with the compiled case
labels. The full generated ordering suite was rerun, including wrong-message
rejection and both KEX exchanges/rekeys.
5. Production macro replay confirms `WOLFSSH_NO_RSA`, `WOLFSSH_NO_DH`, and disabled
certificates. Client code is not generally compile-disabled: ECC client parser
safety depends on the role/ordering contract, not on dead-code assumptions.
**Disposition:** retain both key parsers byte-identical, fence the call chain and
ordering source, and require this decision to be revisited before enabling client
use or widening KEX. No standalone client parsing safety claim.
### CHANNEL_FAILURE correction and behavior
The pinned `DoChannelFailure` did not read a recipient at all: `len != 0` returned
`WS_BAD_ARGUMENT`; an empty payload returned `WS_CHANOPEN_FAILED`. It did not have
the out-of-bounds read implied by blindly treating it as the newer parser. But
CHANNEL_FAILURE is a connection-protocol message allowed after authentication by
the current server gate, so its malformed-input contract is relevant even though
the application has no useful outstanding channel-request workflow needing it.
The local adaptation validates pointers, uses `GetUint32` on a local cursor,
requires exactly one remaining recipient field (`begin == len`), and verifies the
recipient through `ChannelFind(..., WS_CHANNEL_ID_SELF)`. Only then does it publish
the cursor and return the existing `WS_CHANOPEN_FAILED`. Truncation/wrapping offsets
and trailing data return `WS_BUFFER_E`; an unknown recipient returns
`WS_INVALID_CHANID`. Failure leaves the caller index unchanged; no channel/session
state is mutated. No new queue, allocation, retry or callback is introduced.
This **does not turn CHANNEL_FAILURE into a recoverable reply** or implement
request correlation. Both old nonempty rejection and new parsed failure remain
fatal to this application's worker path, which treats only its explicit
would-block/receive statuses as retryable. The correction establishes bounded
framing and the appropriate existing failure result, not an authentication-bypass
or memory-corruption exploit fix. It is a local adaptation, not a full PR899 patch.
## PR902 and present channel callback policy
The old bounded `DoServiceRequest` accepted any short service string and advanced
to `CLIENT_USERAUTH_REQUEST_DONE`. PR902 really is applicable before user
authentication: ordering permits SERVICE_REQUEST at `ACCEPT_KEYED` but does not
validate its name. The generated handler now requires length 12 and exact bytes
`ssh-userauth`; mismatch returns `WS_INVALID_STATE_E` before index/state publication.
Length comparison short-circuits before the fixed-span comparison. Existing bounds
and the strict name-capacity limit remain. Unlike upstream's later-tree patch,
this subset does not queue a best-effort disconnect: the owner already closes on
this error. Valid-service transition is unchanged. `DoServiceAccept` is unchanged;
the current server gate rejects SERVICE_ACCEPT before dispatch.
For the channel/forwarding question:
- Actual Xtensa replay confirms `WOLFSSH_FWD`, `WOLFSSH_AGENT`, `WOLFSSH_CERTS`,
`WOLFSSH_SFTP`, and `WOLFSSH_SCP` absent. PR918's `DoGlobalRequestFwd` call sites
and PR919's direct-tcpip handling are under `WOLFSSH_FWD`. Unsupported forwarding
channel types take the default unknown-type failure before channel allocation;
global forwarding requests fall through to failure if a reply is requested.
- `create_context` registers shell, exec and subsystem callbacks, not a channel-open
or global-request callback. Default session-channel acceptance is intentional:
the pinned handler limits it to one channel, and auth ordering precedes it.
- Shell callback marks `shell_requested`; exec/subsystem callbacks reject.
`process_handshake` additionally requires an authenticated/current principal,
that flag and `WOLFSSH_SESSION_SHELL` before broker/admin routing. Callback
rejection alone is not the whole policy: the library stores session type and
completion state even for rejected requests, while the application gate stops
exec/subsystem admission. The registered callbacks never execute commands.
- The pinned generic channel-open callback rejection path appends the channel even
after callback failure; no callback is installed here, so that dormant path is
not patched by this review. Revisit before adding one. Do not infer that PR919
repairs generic channel-open callbacks; its archived hunk is forwarding-only.
### Completed follow-up: exact channel-request names
The concrete prefix issue is **closed**, without refactoring the request parser.
All nine `WSTRNCMP(type, literal, typeSz) == 0` predicates are replaced by
`typeSz == sizeof(literal) - 1 && WMEMCMP(type, literal, sizeof(literal) - 1) == 0`.
The length check short-circuits before any comparison on a short name. `memcmp`
compares through embedded NULs instead of accepting a terminated prefix. Existing
bounded `GetString` copies at most 31 bytes; every recognized name is shorter, so
an oversized name truncated to 31 bytes cannot alias a recognized name. No new
allocation, helper, protocol response, state transition or feature setting.
The full handler and application gates were rechecked, not just the shell branch:
| Exact name | Existing branch / actual production gate |
| --- | --- |
| `env` | Parses two strings; no environment-setting callback. Always compiled. |
| `shell` | Sets shell session type, calls registered `accept_shell`, marks library completion. Application still requires the callback's `shell_requested` flag and shell session type. |
| `exec`, `subsystem` | Parse command, store their session type, call registered rejecting callbacks. Library completion is not application admission; no command is executed by these callbacks. |
| `pty-req` | Under `WOLFSSH_TERM`, **present** in production. Parses term/dimensions/modes; resize callback is optional and not installed by this application. Does not authorize a shell. |
| `window-change` | Requires both `WOLFSSH_TERM` and `WOLFSSH_SHELL`; **absent** because production has no `WOLFSSH_SHELL`. Remains on the unknown path in that profile. |
| `exit-status`, `exit-signal` | Under TERM or SHELL; **present** via TERM. Existing payload parsing preserved. |
| `auth-agent-req@openssh.com` | Under `WOLFSSH_AGENT`; **absent**. Optional enabled-branch comparison tested only in a host fixture, not enabled in firmware. |
Empty names, proper prefixes, same-prefix suffixes, same-length wrong bytes,
embedded NULs and overlong names no longer select any recognized branch. They take
the **unchanged unknown-request path**: no branch callback/session-type update,
consume the payload and return success (send channel success if requested).
This deliberately does not introduce unknown-request rejection or strict trailing
payload validation. Malformed header/name/boolean framing still fails before
lookup/callback. Existing exec/subsystem behavior of calling their rejecting
callbacks even after a command-payload parse error is also preserved; those real
callbacks cannot execute commands. A prior accepted shell does not authorize a
later exec/subsystem: the actual application session-type gate still rejects it,
with or without a requested reply. No unauthenticated route is introduced.
`channel_request.c` executes actual generated helpers and the complete handler.
`channel_request.py` separately hash-pins and extracts the real `accept_shell`,
`reject_channel_request` and complete `process_handshake` bodies, checks their
registration/context wiring, and executes them with platform/routing doubles.
Tests exercise both broker/admin shell admission and rejection for missing callback
context/flag, missing authentication/principal, stale principal, non-shell session
and unsupported role. This is not a live broker/admin or task-lifecycle test.
## Generic signature API: finite caller closure, not library closure
Rechecked exact pinned `signature.c` and generated SSH calls, supplementing the
[key-validation trace](ssh_key_validation_review.md#raw-signatures-and-cve-2026-5194-applicability):
- `wc_SignatureVerifyHash` and `wc_SignatureGenerateHash_ex` reject zero sizes and
invalid hash types but do **not** require the supplied hash length to equal the
algorithm's digest length. That generic weakness remains; no global PR10131
backport or crypto configuration change is made.
- Current server ECC authentication is the sole enabled SSH
`wc_SignatureVerifyHash` caller. `DoUserAuthRequestPublicKey` derives the digest
size from `HashForId(pkTypeId)` and `wc_HashGetDigestSize`, checks errors, hashes
locally, then passes it to `DoUserAuthRequestEcc`. Authorized P256 implies SHA256,
32 bytes. A peer signature field does not supply this digest length. The other
SSH VerifyHash call is certificate-gated and absent.
- `SignHEcdsa` hashes exchange H locally using the negotiated P256 host-key hash and
calls `wc_ecc_sign_hash` with the full 32-byte digest. Ed25519 authentication uses
streamed message verification, not generic prehash verification.
- Both `wc_SignatureVerify` call sites are in blocked client `DoKexDhReply`; that
wrapper also derives/hashes a full digest internally. Client ECC auth signing
and certificate signing are not current server paths; agent signing is disabled.
No application `src/` call to generic signature generation/verification APIs or
raw `wc_ecc_sign_hash`/`wc_ecc_verify_hash` was found outside these vendor paths.
- No wolfSSL TLS context/connect/accept use was found in application `src/`;
HTTPS uses mbedTLS. This is application reachability evidence, **not** a claim
that wolfSSL TLS or generic ASN/signature APIs are compiled out or fixed.
**Closed question:** no short-digest/OID-confusion trigger in this inspected current
SSH caller set. **Reopen on:** certificate/client/agent enablement, new raw API
callers, key/KEX widening or a changed authorization/hash construction. General
wolfSSL TLS/ASN/API auditing remains outside this finite scope.
## Validation and remaining handoff
Executed in this slice:
| Command (all prefixed `CCACHE_DISABLE=1`) | Result |
| --- | --- |
| `python3 tests/wolfssh_parser_contract/run.py` | PASS: existing 3,258 cases × two stack modes / 11 mutations, plus channel matrix below / 20 additional rejected mutations; independent full-source/provenance fences. |
| `python3 tests/wolfssh_parser_contract/review.py --profile` | PASS: actual saved Xtensa feature replay and fresh-source syntax. Explicitly reports production input is the reviewed **prior** baseline. |
| `python3 tests/wolfssh_auth_contract/run.py --host-only` | PASS: 135 password/control-flow/wipe cases. |
| `python3 tests/sdk_security_overrides/run.py` | PASS: generator and CMake fixtures, including existing SDK corrections. No actual build-registration option used. |
| `python3 tests/wolfssh_order_contract/run.py` | PASS: 8,028 full-generated-source/real-crypto checks and seven rejected mutations. No OpenSSH interop option used. |
| `python3 tests/wolf_crypto_policy/run.py --host-only` (initial review, not rerun for name-only follow-up) | PASS: 20 guards, PUBLIC CMake fixture, real vendor small-math/P256/ASN vectors. No strict production crypto rerun. |
| `python3 tests/ssh_protocol_policy/run.py` (initial review; not rerun while build remains stale) | **Blocked as expected:** `Generated wolfSSH source differs from render_entry; reconfigure the build`. Its strict guard was not changed or bypassed. |
Follow-up channel matrix: **2,737 cases per stack mode** for production TERM-only,
TERM+SHELL, and TERM+SHELL+AGENT profiles; **2,735 per stack mode** for no-terminal
and SHELL-only profiles. All five profiles run both stack modes with guard pages
and UBSan trap instrumentation. The alternative features are host-only coverage,
not production settings. Tests cover every proper prefix, valid names, appended
bytes/NUL suffixes, every embedded-NUL/same-length wrong-byte position, 3165-byte
names, every packet/payload truncation, oversized/wrapping declared lengths,
nonzero offsets, want-reply both ways, known/unknown channels, PTY callbacks and
real application admission. Instrumented comparison asserts that the compared
span equals the initialized name length. **18 name/length mutations and two real
application shell-admission gate mutations are rejected**, in addition to the
existing 11 parser mutations. Ordering/auth/SDK suites and Xtensa profile/syntax
were rerun successfully after the follow-up; `git diff --check` also passed.
The new parser tests use crypto/channel doubles; they establish dispatch/gating
and preserved state contracts, not cryptographic arithmetic or actual channel lifetime. The
ordering suite supplies separate full-library host evidence. No resource/timing,
firmware link, device, broad fuzzing, new network SSH or Phase9 acceptance claim.
Remaining handoff is bounded:
1. Parent-approved regeneration/build and strict production-source suites after
integrating concurrent work; production generated bytes are deliberately stale.
2. The requested channel-name dispatch and callback-gate review is **complete**.
Existing unknown/trailing-payload behavior is explicitly preserved, not certified
as a generally strict parser and not expanded into another parser inventory.
3. Existing whole-phase hardware/rekey cleanup, compatibility and heap/stack/CPU
gates remain as recorded in the ordering/key-validation reviews. No new target
cost measurement is claimed for these allocation-free checks.
4. Dormant client-key/forwarding/certificate/generic-API defects are documented
profile exclusions, not fixed dependency features. Re-audit only if those
capabilities or the pinned source/role/ordering contract change.
+36 -16
View File
@@ -1,6 +1,28 @@
# User administration and authentication tests
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe the behavior of those historical implementation baselines; they are not the current end-to-end acceptance behavior. In current Phase 8C firmware, HTTPS and SSH authenticate through the user database, role-`user` SSH sessions receive the broker-backed serial stream, and role-`admin` SSH sessions receive the administration shell. Use the Phase 8C section for current routing and shared-console validation. Never include generated or entered passwords in test logs.
Reusable procedures, **not execution evidence**. [Phase 8 is accepted](web_administration_acceptance.md); overall 8D.22 user sign-off does not assert that every checklist item below ran. Historical 8A/B sections describe their original baselines only; current cookie authentication, removed bootstrap/credentials and UART0-only recovery override them. Use current contracts and the integrated regression section for present behavior. Never log generated/entered passwords, cookies/tickets or private/verifier material.
## Current Broker management regression procedure — 8D.16
The [Broker contract](web_administration.md#broker-and-contextual-controls) defines current API, generations, wrap safety and bounds. Retain these checks for future regressions; acceptance is recorded separately.
- Verify admin-only detailed client rows against non-consuming UART0 snapshots/counters; direct normal-user management GET/POST denied. Observe zero/one/full client mix without reading UART payloads as a probe.
- Opening, selecting, cancelling, refreshing and navigating must leave writer and both terminals unchanged. Explicit full-page Refresh clears selection; contextual refresh retains selected target/version and sticky stale/absence latches until deliberate reselection. Native confirmation must identify exact target and snapshot writer.
- Assign among USB/SSH/two browser serial clients with both admins connected. Disconnect/reuse target while confirmation is open; race writer release/reacquire, competing requests, shell force and local release. Stale confirmation must fail without changing the intervening lease; Refresh/reselect/confirm explicitly.
- Test separate-login result isolation, same-login tab replacement, pending capacity, lost ACK/result, bounded polling/manual recovery, logout/expiry/revocation and HTTPS stop/restart. Never infer cancellation from connection loss or replay automatically.
- At signed-off 160 MHz/230400 baud, check full-mix binary traffic/drop isolation, UART0/USB recovery and optional-route failure isolation. Capture exact revision/mix and internal/DMA/PSRAM plus HTTPD/dispatcher margins; host tests/build do not approve reserves or target throughput for an unmeasured workload.
## Current Network settings regression procedure — 8D.12/8D.13
The [Network contract](web_administration.md#network) defines the current byte codec, owner/persistence semantics and uncertainty. Retain these regression procedures without inferring individual execution from overall sign-off.
- Verify admin-only Settings/Network and direct-route normal-user denial, current cookie/principal, body/query/framing/Origin/CSRF checks, unavailable/contended snapshots and generation races against CLI/local controls.
- Round-trip UTF-8 and arbitrary SSID bytes through text/hex, including NUL/BOM/non-UTF-8 and 32-byte boundaries. No saved PSK or length may appear in responses, status/logs/completion/local display. Keep omits credentials; Replace never accepts blank; disabled-STA Clear (including disable+clear) works; enabled-STA/AP clear is denied even when AP policy is off. Check transient-input expiry and context/session clearing.
- Distinguish RAM Apply, explicit Save, stored-only Wi-Fi Load and reboot persistence. Missing/invalid/failing Wi-Fi storage must not install generated defaults or new AP secrets. Exercise stale generations, queue failure/drop accounting and NVS failures without secret logging. No Wi-Fi reset/default/export action exists.
- Prepare UART0 and USB before confirming disruptive actions. Test Start/Stop (including RAM boot policy), Reconnect/Next, AP policies and stopped no-ops. The selected profile is an edit target, not explicit connection selection; Next uses canonical priority/wrap. Cancel confirmations and exercise lost ACK/401/disconnect, manual Check Result/Refresh, another-tab result replacement and no automatic replay. `accepted` is not online; delivery before disconnection is not guaranteed. Reconnect via STA/AP; UART0 administers recovery, USB preserves independent UART1 access.
- Test mDNS generation/Set/Save/Load/Defaults, live/offline reannouncement, next STA IP, init/live failure isolation and `applied_not_queued`. Verify actual client DNS and changed-hostname browser trust/login, not merely `announced`. Confirm no unintentional Wi-Fi secret reset.
- With USB/two web serial/SSH serial and both admin routes, verify hidden output draining and writer/observer preservation through Settings navigation. Separate actual network-disruption losses from serial/broker regressions. Exercise optional Network route/timer failure and stop/restart without taking down unrelated routes. Browser-shell restrictions remain unchanged.
- Capture boot/full-mix internal/DMA/PSRAM free/minimum/largest blocks and memory floors during TLS/Network operations; timer heap/slot costs, repeated-operation cleanup/soak and **HTTPD/dispatcher stack margins** remain required. Record exact revision/client mix and nonsecret counters, including broker and manager queue drops. Host tests do not establish target reserve or hard scheduling/cancellation guarantees.
## Current legacy-removal regression procedure
@@ -14,7 +36,7 @@ The legacy bootstrap/credential/reconciliation instructions in the phase baselin
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.
Acceptance and prior evidence limits are recorded in [legacy compatibility](legacy_credential_removal.md) and [overall acceptance](web_administration_acceptance.md); the preceding procedure is not an execution log.
## Historical Phase 8A baseline — role-based database and UART0 administration
@@ -180,19 +202,9 @@ While an administrative command is queued or running, use UART0 to change that a
Finally, issue commands concurrently from UART0 and admin SSH, including `user list`, long `help` output, and one UART0 interactive password or key prompt while an SSH command waits. Confirm the single dispatcher serializes all `esp_console_run()` calls, UART0 retains its line editing/history/completion, prompt input is consumed only from UART0, outputs are not mixed between transports, and there is no stack overflow, corrupted argument parsing, database damage, or broker disruption.
## Planned Phase 8D integrated web administration
## Integrated web administration regression procedure
These are acceptance requirements; listing an item does not claim its execution. The [8D.3/M1 checkpoint](phase8d3_implementation.md) is host-tested/build-verified and validated by explicit user sign-off on 2026-09-06 after both-role login, mixed-client operation and post-soak evidence. Unrecorded individual checks remain coverage/evidence limitations, not blockers to that sign-off. Browser administration remains planned. Execute them incrementally using the [Phase 8D work-unit plan](phase8d_plan.md), not only at the end of the phase.
Validation checkpoints:
- **8D.0:** Establish working browser login/serial, UART0/USB/SSH, and measured build/runtime memory baselines before changing authentication. Record the revision/configuration, workload, numeric resource budgets, and repetition counts.
- **8D.18D.3 / M1:** Exercise session primitives and session-specific cleanup as they land, then run section 1's authentication checks against the real login page and existing serial UI. Administration-denial checks apply as each admin route is added. Test new and previously Basic-authenticated browser profiles and direct-IP/mDNS access where available. **Stop for target/browser validation before adding the admin shell.**
- **8D.48D.7 / M2:** Regress UART0/admin SSH after the console-boundary change; then run section 2 and the applicable section 5 concurrency/failure checks with both browser WebSockets active. **Stop for target/browser and memory validation before settings.**
- **8D.88D.21:** Run sections 34 one settings/control domain at a time, including direct server-side role/CSRF/origin denial, malformed/oversized input, persistence, and applicable lifecycle/revocation checks. Reuse previously verified endpoints for popovers rather than postponing API validation until popovers exist.
- **8D.22:** Run all sections together plus the bounded concurrent soak. This is cumulative acceptance, not a substitute for the earlier checkpoints.
Every runtime-changing chunk requires a bounded `pio run`, the small transport/login smoke check, and its focused acceptance checks. Record **implemented**, **build-verified**, and **target-verified** separately. Measure UART0 `memory` internal/PSRAM free, minimum-free, and largest-block values at settled boot, connected load, and after repeated logout/disconnect/reconnect; include stack margins where available and compare incremental and cumulative resource costs. Record pending hardware checks or timed-out commands honestly; neither is a pass. The incremental plan defines the stop/split policy if a resource budget or validation gate fails.
Current retained-scope checks for future changes. [8D.22 sign-off](web_administration_acceptance.md) closes the phase; these are **not additional acceptance conditions or claims of individual execution**. Record exact revision, workload/client mix, duration, counters and heap/stack evidence for any new run. Do not treat host doubles as target timing/power-loss proof or reserve approval. Prepare UART0 recovery/native USB before disruptive tests and do not erase persisted data without explicit approval.
### 1. Integrated login and authorization
@@ -202,7 +214,7 @@ Authenticate as both roles through the same-origin login page, explicitly log ou
As an administrator, connect the browser serial terminal, acquire the writer lease, send and observe serial data, then switch repeatedly between **Serial terminal** and **Admin shell**. The visible terminal contents and input route must change, but `broker clients`, the displayed browser client ID, and active writer ID must remain unchanged. Request control/Release control and writer/observer state must stay visible in both modes. While Admin shell is selected, have a normal user request the writer lease and confirm the retained browser lease prevents unintended takeover. Only explicit Release control, confirmed writer transfer, Disconnect, logout, revocation, expiry, or connection failure may release it.
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. Physical-only bootstrap/recovery commands remain rejected. Closing only the admin-console route must leave the browser serial client and its lease intact.
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. First-admin provisioning remains UART0-only, remote recovery is rejected, and the removed bootstrap command is unavailable everywhere. Closing only the admin-console route must leave the browser serial client and its lease intact.
### 3. Quick settings and client popovers
@@ -212,8 +224,16 @@ Open **Broker clients** and confirm its secret-free list matches authoritative b
### 4. Typed settings and destructive operations
Exercise user/password/role/key management, serial settings and persistence, Wi-Fi profiles/AP policy/secrets, service/session controls, display settings, and network diagnostics through typed bounded APIs. Compare resulting subsystem state with the equivalent canonical CLI behavior without routing API requests through command strings. Generated passwords appear once in no-store responses; destructive or self-terminating HTTPS/SSH/reboot/security actions require explicit confirmation and explain the expected connection loss.
Exercise user/password/role/key management, serial settings and persistence, Wi-Fi profiles/AP policy/secrets, retained SSH service/session controls, display settings, HTTPS lifecycle/reboot and HTTPS/SSH rotation through typed bounded APIs. Dedicated typed network diagnostics and ordinary browser-session/native-USB management are excluded; exercise existing shell diagnostics only through permitted frontends. Compare resulting subsystem state with the equivalent canonical CLI behavior without routing API requests through command strings. Generated passwords appear once in no-store responses; destructive or self-terminating HTTPS/SSH/reboot/security actions require explicit confirmation and explain the expected connection loss.
### 5. Concurrency and failure isolation
Run USB, browser serial, browser admin shell, user SSH, admin SSH, UART0, and active UART1 traffic concurrently. Alternate explicit writer transfers while issuing administrative commands and opening/closing popovers. Verify one writer, isolated observers, bounded memory/queues, principal revocation, no mixed admin output, no hidden lease loss during terminal switching, and continued UART0/native-USB recovery if web-session or admin-console initialization fails.
### 6. Service identity, lost acknowledgement and recovery
Confirm public fingerprints and both service/identity versions before HTTPS/SSH rotation. Race canonical CLI changes with open confirmations; stale operations must reject before mutation. For HTTPS, exercise send-return/ID-callback admission, accepted-but-lost work and failed destruction without accumulating callbacks; restart must retain owner reservation while deliberately invalidating logins. For SSH, failed stop must skip mutation/start and retained context must survive until every slot retires. Inspect precommit/postcommit partial effects without assuming an error means no change; never replay automatically. Verify new trust via UART0, fresh HTTPS login after restart, stopped-rotate versus CLI-reset behavior, and continued unrelated-service recovery. Browser identity Reset/recovery/export is not part of the workflow.
### 7. Display, API bounds and optional failures
Exercise Display generation conflicts, explicit RAM/Save/Load/Defaults/Reset, absent panel with available UI task and concurrent button activity. Reset storage failure must leave RAM unchanged. Across all typed domains check body/receive/schema limits, optional registration failures, one-slot/login isolation, queue expiry versus admitted execution, saturation and stale IDs. Secret timers cancel/wipe only non-executing work; logout/deadline cannot recall admitted commits. Compare Serial service discards with preserved broker clients/lease/output, rather than claiming uninterrupted data through explicit reconfiguration.
+128
View File
@@ -0,0 +1,128 @@
# Web administration contracts
Current, accepted firmware behavior. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](web_administration_acceptance.md), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
## Authentication and admission
- HTTPS-only, same-origin login replaces Basic authentication entirely. Four digest-only opaque sessions have absolute one-hour expiry; four pre-login challenges last 120 seconds. Login is globally bounded to five credential attempts per 60 seconds. Live sessions/challenges/tickets are not evicted to admit another client.
- Raw tokens travel only in the host-only `__Host-` cookie with Secure, HttpOnly, SameSite=Strict, Path=/ and explicit lifetime; no Domain. Session records retain copied secret-free principals, CSRF state and nonzero, nonreused originating-session IDs. Passwords, raw cookies/tickets, verifiers and private keys never enter routine snapshots/logs/completion/display.
- Mutation admission requires current cookie/principal, strict Origin and CSRF validation; administration additionally requires current `admin`. Normal users retain serial/status but cannot invoke administration directly. Authentication POST fetches use CORS mode with fixed same-origin URLs and same-origin credentials: do not accept Origin `null` to compensate for browser no-referrer behavior.
- Session-store initialization is part of admitted HTTPS start; authentication failure gates HTTPS. Failed start/accepted stop disables and wipes session state. Logout invalidates only the originating session before socket cleanup; account mutation invalidates that account's sessions/tickets, including deletion/recreation, without revoking unrelated accounts. Currentness checks remain authoritative if best-effort notifications fail.
- Four serial tickets and two admin tickets are digest-only, single-use, 30-second, session/principal-bound records. Cookie/Origin/ticket/currentness and transport admission precede explicit WebSocket 101. Store RNG/SHA/database calls run outside short spinlocks; IDs/expiry/epochs fence stale publication without nested store/transport locks.
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 39 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
`web_httpd_adapter` alone accesses private IDF 5.5.0 HTTPD state. It rejects duplicate/ambiguous headers, postpones 101 until admission, and wipes consumed header scratch while preserving right-aligned unread bytes. Optional Settings registration stages descriptor/name allocations before publishing either, avoiding the pinned public registration failure path. Re-audit these private boundaries on SDK upgrades and same-version SDK patches: the version guard does not detect patches that retain the same version number. HTTPD response headers are pointer-backed, not copied; both `Set-Cookie` value buffers must remain valid and distinct through response send. Do not reuse or wipe those buffers before sending completes. Do not enable header/ticket debug logging. Auth documents, scripts and sensitive responses are no-store with CSP/no-referrer/frame-denial protections; authored loader changes require matching CSP hashes. Generated assets are not a normal documentation/build output.
## Terminal and console ownership
Switching Serial/Admin/Settings never creates a second serial client, requests/releases the writer lease or reconnects serial. Hidden connected terminals keep draining; only selected terminal input is sent. Both browser terminals have separate 5,000-line scrollback and 64 KiB callback-accounted pending-output bounds with visible browser-drop counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Admin open/reopen is explicit; admin close is isolated. Disconnect pauses serial reconnect but retains login; logout/expiry/revocation closes affected sockets normally.
The document binds terminal state to its first validated username/role/session-stable CSRF tuple. A different identity requires a clean document before showing retained output. Pagehide fences sockets/work and hides scrollback until same-session revalidation; admin is not automatically reopened. Fit readiness caches only success and uses at most three generation-fenced animation-frame retries per external request.
`admin_ssh_console` is the sole `esp_console_run()` caller for UART0, admin SSH and browser admin. Two fixed remote-console slots are shared across SSH/browser, not two per transport. Queue records carry copied principals and transport-qualified generation tokens. Owner currentness is checked outside console locks before commands and during prompts (250 ms polling plus validation/scheduling latency), then identity is rechecked. This is operation admission, not cancellation or rollback of arbitrary executing handlers. Consumed output and retired prompt/history state are wiped.
HTTPD alone owns browser-admin socket IO and its 1,552-byte PSRAM-only payload. A 20 ms timer queues at most one owner poll, not a new task. Close uses current-owner shutdown, not queued reusable `sock_db *` pointers. Detach fences submitters; queued state retires only after successful HTTPD destruction. Failed destruction retains ownership and prevents unsafe restart/reuse.
### Browser-shell policy
Typed Settings permissions do not expand shell permissions. Parsed canonical arguments, not raw prefixes or completion suggestions, control admission:
- Browser `web` permits only `web status`, `web stop`, and exact `web certificate rotate --force`; certificate info/reset, diagnostics/performance and other web forms are denied.
- Browser `wifi`/`mdns` permit only status. Network mutation belongs to typed Settings or UART0/admin SSH.
- Browser `user` permits status/list/show and interactive add/password plus forced role/delete for **other accounts only**. Self changes, generated passwords, key commands and recovery are denied there; typed Accounts supports the separately bounded self/generated/key workflows.
- Browser SSH stop/disconnect/reset and host-key mutation are denied; typed SSH Settings has its own safe owner path. Do not claim full browser-shell parity.
- Browser `reboot` and owner-relative `exit` are supported. First-admin provisioning uses normal `user add` on UART0; unavailable-database recovery is UART0-only. The legacy `user bootstrap` and web credential commands no longer exist.
Self-affecting shell actions use the existing bounded drain/control path (up to ten seconds plus a short delay), not guaranteed peer delivery. Browser certificate rotation hands a typed action after drain/200 ms to the existing 12 KiB dispatcher, never crypto/NVS on the 4 KiB control stack. Pending input is discarded through execution, and an executing slot remains reserved across self-detach. UART0/admin SSH retain canonical recovery actions.
## Typed settings API and operation lifetime
All routes below are under `/api/settings/`. Each domain has bodyless GET snapshot plus GET/POST operation unless noted. Requests reject queries, malformed/oversized/duplicate/unknown fields and inappropriate bodies; mutation routes require bounded JSON with current admin/Origin/CSRF. GET allows absent Origin but rejects mismatch. Snapshot contention/unavailability is not an empty successful projection. HTTPD performs bounded admission/encoding only; canonical mutations run on the existing four-entry administration dispatcher using **IDs only**, not command strings, credentials, request pointers or socket handles.
| Domain and source | Snapshot / operation suffix | Request / snapshot / result buffer bounds (bytes) |
|---|---|---|
| `web_serial_settings`, serial service | `serial` / `serial-operation` | 256 / 256 / 96 |
| `web_account_settings`, user database | `accounts` / `account-operation` | 768 / 1024 (accounts), 512 (keys) / 96 |
| `web_network_settings`, Wi-Fi + mDNS | `network` / `network-operation` | 768 / 2048 / 128 |
| `web_display_settings`, local status UI | `display` / `display-operation` | 256 / 128 / 96 |
| `web_broker_settings`, session broker | `broker` / `broker-operation` | 256 / 2048 / 96 |
| `web_ssh_settings`, SSH owner/security | `ssh` / `ssh-operation` | 256 / 768 / 96 |
| `web_lifecycle_settings`, HTTPS owner/security | `lifecycle` / `lifecycle-operation` | 256 / 320 / 96 |
Mutation bodies use at most four receive attempts. Each domain retains one original-login-bound pending/result slot, nonreused operation IDs and an executing reservation. Results are replaceable, login-isolated observations, not durable history or idempotency keys. Another tab can replace a completed result. Dispatcher currentness and the 30-second dequeue deadline precede canonical owner admission. **Admitted work may finish after logout, revocation, timeout or navigation.** A failed response, 401 or disappearance of a result proves neither success nor cancellation; inspect current state before any deliberate retry.
Accounts and Network each use a one-second timer to cancel/wipe queued, non-executing secrets after 30 seconds plus scheduling latency. Shared input wipes on dequeue/rejection; executing locals wipe on return. This is not a hard wall-clock erasure/execution guarantee. Other domain deadlines are dequeue checks, not new cancellation timers. UI fences late/session-changed responses, bounds request/result checking to 15 seconds and never automatically replays/restores mutations. Serial/Accounts/Network/Display/Broker use bounded automatic checks where implemented (at most ten one-second GETs); SSH and HTTPS/Reboot require manual Check Result/Refresh.
### Serial and Display
Serial Apply/Defaults change RAM; Save persists working device state, not browser drafts. Load follows canonical defaults/fallback behavior. Reset uses canonical apply/persist/best-effort rollback. Reconfiguration/stop discards serial-service RX/TX and task-local pending data but preserves broker clients, lease and already-fanned output. USB with DTR may restart a stopped service. `/api/status` uses a zero-wait consistent serial snapshot (`running:null` if unavailable).
Display accepts dim/off timeouts 086400 seconds and an expected nonzero configuration generation. All writers, including CLI/legacy Apply, share a zero-wait owner reservation; NVS runs outside critical sections. Save stabilizes selected RAM; Load retains canonical fallback without rewriting NVS; Reset commits defaults **before** RAM publication, leaving RAM unchanged on failure. Buttons/diagnostic holds update activity, not configuration generation. Configuration needs an available UI task, not a physically present panel, and never takes over I2C.
### Accounts and authorized keys
The database owns eight accounts, final-admin protection and conditional target username/account-ID/auth-generation checks inside its mutation lock. Create/password/role/delete and key mutations share canonical commit/invariant logic. HTTPD uses zero-wait secret-free projections, not blocking CLI snapshots. Successful mutations request target-only web/SSH revocation; self mutation can revoke access before result retrieval.
Separate bodyless POST `accounts/generate-password` returns one 24-character value without mutation or retained retrieval. The browser uses a 60-second context-bound saved acknowledgement before separate submission; this is UX, not delivery proof, and JavaScript strings cannot be securely wiped. POST `accounts/keys` returns only slot/type/SHA-256 fingerprints for the selected identity. Key add/delete/clear share the operation slot; public-key import is at most 384 decoded text bytes in the 768-byte request and uses canonical Ed25519/P-256 validation. Three stable key indices may be sparse; never interpret response-array position as an index. No stored key blob, verifier or password is exported.
### Network
Wi-Fi config/runtime is one zero-wait consistent projection; mDNS is a separate projection, not cross-domain atomic authorization. Four stable profiles carry enabled/priority/security/SSID/password-configured metadata. `mixed` means WPA2-or-stronger, not open. `announced` is expected STA announcement, not verified DNS.
SSID wire values are reversible **bytes**, maximum 32: printable ASCII, standard single-character JSON escapes and `\u00HH`, with no raw non-ASCII, non-byte Unicode or surrogates. NUL/non-UTF-8 round-trip. UI text is UTF-8-encoded before byte serialization; exact reversible text or literal hex preserves existing bytes and BOM, with no silent replacement/truncation.
Flat operations select one domain/target:
- `wifi-patch` + Wi-Fi generation: optional boot policy, AP policy/channel/SSID/password/clear; `profile-patch` additionally selects stable profile 03 and optional enabled/priority/security.
- `wifi-save|wifi-load` require Wi-Fi generation. `start|stop|reconnect|next-profile` use canonical connection controls, not explicit selected-profile connection.
- `mdns-set` requires its generation and suffix; `mdns-save|mdns-load|mdns-defaults` require its generation. Suffix is 155 lowercase ASCII letters/digits/hyphens, no leading/trailing hyphen, producing `sak-<suffix>`.
Omitted fields preserve current bytes under the Wi-Fi mutex. Password Keep omits, Replace is 863 printable ASCII bytes, disabled-STA Clear is explicit; replacement and clear cannot coexist. AP clear is always denied, even while off. No saved PSK or length is returned. Wi-Fi compare/merge/whole-candidate validation and required queue admission precede RAM publication. Save stabilizes selected bytes; Load reads stored configuration only and cannot generate fallback secrets. No browser Wi-Fi reset/default-secret/export route exists.
Edits require explicit Save. Disabled-profile-only edits do not restart active radio; enabled-policy/AP changes follow canonical asynchronous restart. Start/Stop change RAM enabled-at-boot; Reconnect/Next are no-ops while stopped. Next follows enabled priority order with wrap; editor selection is not connection selection. mDNS independently owns generation/persistence; the Wi-Fi manager owns radio/reannouncement. mDNS RAM change followed by queue failure is reported without rollback; its Load may choose deterministic MAC-derived defaults.
Network result fields are `id/action/state/error`: `pending`, `accepted`, `ok`, `failed`, `cancelled`, `stale`, `invalid`, `loaded_defaults`, `applied_not_queued`, or login-isolated `idle`. `accepted` means RAM/owner-queue admission, **not** association/DHCP/DNS completion. `ok` denotes explicit Save. Loss can precede response; recover via STA/AP and inspect before retrying. UART0 administers recovery; native USB provides network-independent UART1, not an admin console.
### Broker and contextual controls
Broker management atomically copies eight compact client rows, writer and lease generation under one zero-wait mutex acquisition, without reading UART payloads or consuming events. Conditional assignment compares the selected nonzero target ID and lease generation under the same force-writer lock before any effects. Stale/absent targets conflict; same-current-target assignment is a no-op only after validation. Accepted serial TX is not recalled by transfer.
IDs have three slot bits and 29 generation bits; exhausted client slots retire until reboot rather than wrap. The separate 32-bit lease version saturates at UINT32_MAX, survives counter clear and advances on grant/release/revoke before advisory event delivery. Forced transfer can advance twice; it is not a count. Saturation blocks typed assignment but leaves ordinary request/release/disconnect and canonical recovery force available. Reboot invalidates old browser sessions.
Serial/Wi-Fi/Broker quick views reuse one nonmodal settings host and existing controllers/drafts, not parallel editors. Hover/focus/click/tap never mutate. Network quick mode excludes password controls. Full-page drafts are protected; promotion preserves controller/selection, departure fences reads/timers without cancelling admitted work. Dismissal restores the prior terminal without stealing unrelated focus; explicit Escape/Close returns focus to its trigger.
Broker contextual reads use one timer five seconds after successful completion and a five-second whole-read deadline, stopping on errors/uncertainty/departure. Explicit target/lease selection is not rebased by refresh. Sticky stale/absence latches require deliberate reselection even if later snapshots match again. Confirmation captures exact versions before session revalidation; guarded aria-disabled controls preserve focus during updates.
## Service lifecycle and identity rotation
Service generations are saturated, distinct from identity and session generations, and advance on admitted canonical lifecycle attempts including failures; counter clear/reinit cannot revive stale versions. Public zero-wait metadata authorizes nothing. Conditional controls compare under the canonical owner reservation; no snapshot-check/unconditional-mutate gap is allowed. Reboot invalidates originating logins and boot-local reservation IDs.
### HTTPS and reboot
GET `lifecycle` has seven fields: `generation`, `running`, `transitioning`, `controllable`, `identity_generation`, `fingerprint`, `rotatable`. Stored HTTPS fingerprint is 64 lowercase hex SHA-256 digits. Stop/restart/reboot require exactly action + service generation; rotate also requires identity generation. Unknown/duplicate/escaped/coerced fields and zero/saturated versions reject. Unavailable identity yields generation zero/empty fingerprint/not rotatable without removing ordinary service controls.
Self-cutting HTTP actions use **successful synchronous send return → one nonreused-ID HTTPD callback → existing dispatcher**. Send return is not browser receipt. The callback never waits or performs lifecycle work, and captures no request/fd/reusable operation pointer. A two-second ACK admission deadline and post-validation 30-second dequeue deadline are not execution bounds. Accepted-but-lost HTTPD work retains one reservation until callback arrival or successful HTTPD destruction; failed destruction cannot release it or accumulate more callbacks. The original login/current admin is revalidated before owner admission.
`web_server_stop_current()`/`web_server_restart_current()` compare and reserve under the server mutex. Restart retains transition ownership through stop/start; failed stop skips start, failed cleanup retains handles and canonical recovery, and reinit preserves the error. `web_server_reboot_current()` reserves the HTTPS generation then uses canonical `esp_restart()` outside locks, not an HTTPD stop wait or self-console cleanup.
`web_server_replace_identity(service_generation, identity_generation, reset, &committed)` reserves **service before identity**, before any crypto/storage. Both nonzero generations mean conditional healthy-running rotation; both zero retain CLI semantics; conditional Reset is invalid. Canonical CLI/browser-shell rotation and direct security rotate/reset share nonreused task-owner-bound identity reservation tokens. Only the owner can replace once/release; exhaustion fails closed until reboot. Crypto/NVS run outside service/security mutexes and spinlocks; commit precedes live publication and old-key wipe. Reservation remains held while reserved start copies the committed pair.
HTTPS ordering is **commit → stop → restart**. Precommit generation/RNG/storage failure leaves identity, HTTPD and logins unchanged (service version may advance after admission). Postcommit lifecycle failure never rolls back identity; failed stop can leave old served and new stored fingerprints different. Running replacement restarts HTTPS; canonical stopped rotation stays stopped, while CLI TLS-only reset can recover unavailable identity and starts a stopped service. Browser has no Reset/recovery/export action.
Save drafts; rotation/restart invalidates all web logins and closes both browser routes. Inspect `web certificate info` through trusted UART0, verify fingerprint before renewing trust, then reload/sign in freshly. Accepting a warning alone is not trusted verification. Use canonical UART0/admin SSH `web stop` / `web start` for retained-server recovery. Network/SSH/USB are not stopped by HTTPS-only operations; whole-device reboot interrupts all transports and loses unsaved RAM.
### SSH
GET `ssh` supplies service/session state plus identity generation, fixed P-256 algorithm, unpadded OpenSSH `SHA256:` base64 fingerprint and rotatable flag. Service actions use exactly `action`, `generation`, `target`; rotate adds `identity_generation` and requires target zero. Start/stop and exact-session disconnect use published state, saturated service generation and the command mutex; exhausted SSH session slots retire rather than wrap. Disconnect success is an owner close request, not completed teardown. HTTPD never calls wolfSSH or waits for the SSH task.
`ssh_transport_replace_identity()` compares/reserves **service then identity before stop or storage**, holding the existing command mutex across **stop → generate/commit/publish → conditional restart**. Canonical UART0/deferred admin-SSH rotation/reset and direct security mutations share the task-bound nonreused identity reservation. Crypto/NVS run outside security locks/spinlocks. Both nonzero versions select conditional rotation (also while stopped); both zero preserve canonical semantics, with no conditional Reset.
Failed stop/timeout skips mutation and never starts again; pending owner work is not cancelled. Persistence failure after successful stop may already have disconnected clients and attempts old-identity restart if previously running. Committed replacement remains committed if restart fails. Stopped rotation stays stopped; canonical reset can recover unavailable material/start stopped SSH. Only the SSH owner frees runtime context after all slots retire, before clearing cleanup admission; start rejects orphan handles. wolfSSH copies DER into its context; caller/candidate/superseded private bytes are wiped.
SSH changes leave invoking HTTPS available, so they use the ordinary ID-dispatcher/result path, not HTTPS's self-cutting ACK gate. UI confirms both versions and all-SSH/session scope, retains stale selection and manual 15-second/no-replay flow. A failed result may represent partial effects. Verify new trust through UART0 `ssh host-key info` before updating known_hosts; no HTTPS relogin is inherently required. Browser Reset/recovery/key export and user authorized-key changes are separate, excluded from host rotation.
## Diagnostics, recovery and scope boundaries
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](legacy_credential_removal.md). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption, secure boot or OTA is implied by acceptance. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
+40
View File
@@ -0,0 +1,40 @@
# Web administration acceptance
## Phase 8D.22 sign-off — 2026-09-13
The user explicitly accepted the tested firmware: **“Yep, I tested the firmware thats a 8d.22 signoff.”** Phase 8D is complete for its retained scope. This supersedes earlier per-slice target-pending, parent-review and integration-acceptance gates; it does not require additional testing to establish the user's sign-off. Phase 8A/B/C were already recorded as complete and target-hardware validated in the roadmap, so [Phase 8 is complete](roadmap.md#phase-8--role-based-users-and-administrative-access--complete).
Accepted functionality comprises cookie authentication, isolated browser serial/admin terminals, typed Serial/Accounts/authorized-key/Network/Display/Broker/SSH/HTTPS settings, contextual controls, confirmed generation-safe writer/service/identity actions and retained UART0/native-USB recovery. [Current contracts](web_administration.md) define actual permissions, bounded failures and partial effects; acceptance is not a claim of unrestricted browser-shell parity.
Scope decisions remain effective: 8D.15 dedicated typed network diagnostics was removed (shell diagnostics retained); the unimplemented 8D.19 ordinary browser-session/native-USB control remainder was removed (SSH controls retained). Browser identity Reset/recovery/export was not added. Phase 9 security/production hardening is next only on a separate user request.
## Latest firmware evidence
The **prior final firmware build passed**, reporting **100,556 bytes linked RAM / 1,828,573 bytes flash**, at **160 MHz**. This is recorded prior build evidence, not a build run during documentation consolidation. Host regression suites and independent implementation reviews were previously reported passing; no new host/runtime test pass is asserted here.
Latest user telemetry, bytes:
| Sample / memory capability | Free | Minimum free | Largest block |
|---|---:|---:|---:|
| Boot internal 8-bit | 59,808 | 58,840 | 31,744 |
| Boot internal DMA | 52,052 | 51,084 | 31,744 |
| Boot PSRAM | 8,196,968 | 8,183,972 | 8,126,464 |
| Loaded after burst, internal 8-bit | 31,508 | 2,052 | 18,432 |
| Loaded after burst, internal DMA | 23,752 | 460 | 18,432 |
| Loaded after burst, PSRAM | 8,136,624 | 8,065,972 | 7,995,392 |
Loaded SSH minimum-free stack was **15,028 bytes**. The capture had two active SSH sessions across the serial/admin roles, two serial WebSockets and USB, with SSH holding the serial writer. Browser admin had been used and then closed; it was **not active in the captured loaded sample**. Web send/queue/protocol error counters were zero; SSH IO errors were zero, with one handshake failure and one session revocation retained without attributing a cause.
Latest broker/serial counters were not supplied, so these transport counters do **not** establish an exact latest zero-drop or byte-integrity result. No latest full-mix-with-browser-admin-active, individual fault-injection, exact duration, soak, cleanup-cycle or reserve-floor result is inferred. Generic SDK TLS `-0x004C` / NET_RECV_FAILED is not an OOM diagnosis. Two boot authentication failures could plausibly involve stale browser cookies, but that explanation is unconfirmed.
## Earlier acceptance retained without replaying the timeline
- M1 browser login/logout and M2 shared browser administration were explicitly accepted by the user; later Serial/account/Network presentation and legacy-credential cleanup also received scoped acceptance.
- The user explicitly accepted the combined binary WebSocket-send fix at **160 MHz, 230400 baud with the full client mix, including browser admin**. That prior acceptance stands independently of the latest capture's closed browser-admin socket. It does not imply an unreported latest exact counter comparison or soak duration. Keep the combined send and bounded failed-send isolation, not the earlier frequency-only experiment.
- Ordinary HTTPS idle cleanup was accepted as working; that is not a guarantee against all future admission failures or owner delays.
## Nonblocking follow-ups and evidence limits
The extremely low internal/DMA lifetime minima remain an unresolved transient-headroom follow-up, **not a blocker reopening 8D sign-off and not an approved reserve**. Capability pools overlap; summed per-region lifetime minima can be conservative/non-simultaneous and do not prove an allocation failure. Do not add internal and DMA numbers together or attribute an error to memory pressure without correlated evidence. HTTPD/dispatcher stack margins, peak correlation, allocation reserve policy and long-run cleanup/soak evidence remain distinct future measurements.
[Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and focused test runners remain available for future changes; listing them is not evidence they all ran on hardware. Documentation-only consolidation changes no firmware, configuration, generated assets or test implementation and performs no build/upload/erase/commit. No new Phase 9 work is authorized by this acceptance.
+32
View File
@@ -0,0 +1,32 @@
# Web admission diagnostics
Current default-disabled instrumentation, not an admission fix or an allocation-failure detector. `src/web_diagnostics.{c,h}` observes public synchronous HTTPS create/close callbacks and the four serial/admin ticket/upgrade handlers. It adds no owner task, queued probe or socket-capacity change. See [ordinary idle cleanup](https_idle_cleanup.md) for the independent cleanup policy and [acceptance evidence](web_administration_acceptance.md) for user reports.
## Usage and interpretation
Use UART0 (preferred during network stalls) or authenticated admin SSH:
```text
web diagnostics clear
web diagnostics enable
web diagnostics show
```
Enable before reproducing the failed third connection. At failure, run `web diagnostics show` promptly, alongside existing `memory`/`web counters`/browser Network timing evidence; repeat the snapshot if admission remains stalled. Then `web diagnostics disable` freezes event retention (live occupancy continues updating); `web diagnostics show` prints retained history, and `web diagnostics clear` erases history/counters without changing live sockets or enable state. The setting is RAM-only and defaults off after boot. Existing browser-shell policy deliberately denies these commands, even though shared completion offers the fixed forms. There is no HTTP diagnostic endpoint.
- Six connection records are maintained from boot even with capture disabled, so enabling on an already loaded server does not mislabel existing sockets. Each successful TLS connection gets a monotonically increasing, non-wrapping, firmware-lifetime 64-bit `conn` sequence independent of fd, cookie, ticket, broker or user identity. Clear, disable and HTTPS restart do not reset it. Internal TLS object identity is used only during synchronous cleanup; no pointer is exported or dereferenced by the console.
- Snapshot output gives its boot-relative `snapshot_us`, post-TLS occupancy split ordinary/serial-WS/admin-WS and each live fd/connection sequence/open time/age. `kind=0/1/2` means ordinary/serial/admin. Ordinary includes every successful TLS socket not yet observed as upgraded, including idle keep-alives, assets/login/settings and sockets not yet used for HTTP. Classification is actual public `httpd_ws_get_fd_info()` state after upgrade-handler return, **not inferred from `ESP_OK`**. No URI string is inspected.
- The fixed 32-entry overwrite ring records successful TLS open, TLS transport-context close, and enter/result for serial/admin ticket and upgrade handlers. `t_us` is boot-relative observation time; result `dt_us` measures the underlying handler only (including its work/IO, excluding entry resource sampling); close `dt_us` is successful-TLS connection lifetime. Open/enter durations are zero. Open/result occupancy includes the connection; close occupancy excludes it. Event IDs survive clear; `overwritten` counts evicted retained records since clear. `unmatched` counts duplicate create/unmatched close; `lost` counts untrackable creates (getter failure, metadata/sequence exhaustion). Nonzero anomaly counters mean occupancy is not trustworthy as complete evidence. Counters saturate.
- `rc` is the exact handler return, **not HTTP status or ticket issuance outcome**: sending a 401/403/503 can return `ESP_OK`. Match browser HTTP status and existing rejection/issuance counters; there is no ticket-value correlation, request ID, authentication identity, header/body/query logging, or allocation-failure attribution. Upgrade success is visible in the occupancy classification. Ring overwrite or clear/toggle during an operation can leave unpaired enter/result records; do not invent a duration for a missing result.
- Each retained event samples free/largest bytes for internal 8-bit, internal DMA and PSRAM 8-bit, plus the current HTTPD task's minimum-free stack **in ESP-IDF bytes**. Capability scans run outside the diagnostic lock. Samples are sequential, not an atomic heap snapshot; overlapping internal/DMA pools must not be added. Stack watermark includes diagnostic call overhead and is not dispatcher margin. `show` does not query a live task handle: resources are historical event samples, not fresh heap values at show time. Use `memory` for current system heap.
- Console snapshots copy only local diagnostic metadata under a short portMUX, never inspect HTTPD session internals or wait for its owner. Printing occurs outside the lock. At most 32 ID-qualified rows and six live records are printed, even during churn; concurrent clear/overwrite is reported as “no longer retained”. Capture epochs reject samples crossing enable/disable/clear, and sequence checks prevent a stale upgrade result from reclassifying a reused fd. No queued diagnostic work exists, hence no outstanding probe or stale queue lifetime to retire on restart. Minimal connection bookkeeping and upgrade classification remain active while disabled; heap scans and event recording do not. Instrumentation still has CPU/static RAM cost, not zero perturbation.
## SDK audit and explicit blind spots
Audited installed PlatformIO ESP-IDF **5.5.0**, `components/esp_https_server/src/https_server.c` (`httpd_ssl_open`, `httpd_ssl_close`), `components/esp_http_server/src/httpd_sess.c` (`httpd_sess_delete`) and `httpd_main.c`. HTTPS performs synchronous TLS creation before the configured `open_fn` and `user_cb` create callback. It installs a transport-context destructor; that destructor invokes the public close callback before deleting TLS/freeing the context. HTTPD's default close closes the fd, then clears contexts, then frees its session slot. The diagnostic close therefore marks a cleanup observation, not a FIN timestamp or causal close reason. It uses the stored fd, not a getter on an already-closed socket. **Neither `open_fn` nor `close_fn` is replaced**, preserving all existing HTTPS cleanup ownership and failure behavior. Callbacks execute synchronously under the existing HTTPD lifecycle; successful stop finishes cleanup before restart, while failed/partial stop retains remaining live metadata. No asynchronous fd-only events are consumed, avoiding event-delay/fd-reuse ambiguity.
This deliberately bounded first slice does **not** measure TCP connect/accept/listen backlog, pending clients when IDF stops accepting at capacity, handshake begin/duration/failure, TLS allocation failure or aggregate lwIP socket pressure. The public configured open hook is post-TLS too; adding it would not fix these blind spots. No owner-queued client-list probe is added. Occupancy is an owner-published **successful-TLS lower bound**, not the complete HTTPD session table while a handshake is in progress. A clean **6/6** snapshot supports established-connection saturation at that instant (three WS + three ordinary is directly distinguishable); fewer than six does not exonerate admission/TLS/global socket pressure. Correlate time with browser evidence and existing secret-free TLS errors; do not call this pre-TLS tracing or claim the root cause is proven. Existing capacities, receive/send/handshake timeouts and accepted admission issue remain unchanged.
## Regression entry points and limits
`python3 tests/web_diagnostics/run.py` covers bounded metadata/ring behavior and SDK guards; `python3 tests/web_admin_transport/server_lifecycle.py` covers lifecycle composition. These are reusable host commands, not a new execution claim. Doubled TLS/scheduling and host tests cannot establish target admission latency, reserve floors or fault causes. Capture on UART0 alongside `memory`, `web counters`, browser timings and non-consuming broker/serial counters, without cookies/tickets/headers. Generic TLS NET_RECV_FAILED (`-0x004C`) and NET_CONN_RESET (`-0x0050`) are not OOM diagnoses.
+77
View File
@@ -0,0 +1,77 @@
# Web serial throughput diagnostics
## Current contract and accepted fix
Default-disabled instrumentation measures broker fan-out and binary WebSocket TX, not CPU usage, peer receipt or pure scheduler latency. The user separately accepted the combined binary header/payload-send fix at **160 MHz / 230400 baud with full mix including browser admin**. [Acceptance evidence](web_administration_acceptance.md) distinguishes that report from the latest capture and unmeasured reserves. The old frequency-only experiment is not the retained solution.
`web_httpd_ws_send_binary` is an IDF-5.5.0-pinned owner-only session-override send with a bounded 516-byte header/payload scratch copy and 512-byte payload. Generation validation and one outstanding item per slot remain. A non-full send installs a reject-only override before close/shutdown, preventing SDK automatic PONG/CLOSE from reentering TLS with different arguments after incomplete output, even if shutdown fails. No retry/replay; HTTPD owns TLS destruction. Text/control/admin retain the SDK sender. One API call does not promise one TLS record/packet or peer receipt. Keep CPU160, priorities, scheduling and 4096-byte broker/512-byte web payload bounds unless separately changing them with evidence.
## Broker accounting
`broker counters` retains global totals and adds one row per active client: generation-safe ID, type, pending bytes, output high-water mark (HWM), UART bytes considered for that client, queued bytes, read bytes and dropped bytes. `broker clients` helps map the IDs to active transports; `web performance show` supplies each web slot's broker ID.
- HWM is peak output occupancy, bounded by 4,096 bytes. `broker clear-counters` clears totals but seeds each active HWM with current pending occupancy, not zero. It does not drain queues or change ownership.
- `read` means handed to a transport, not received or rendered by a peer. Queued counts successful copies; UART counts bytes considered while the client was connected.
- Disconnected per-client rows disappear and slot/generation reuse resets them. Global counters retain disconnected traffic until cleared, including accepted-but-unread output discarded on disconnect. Thus global drops need not mean only overflow, and queued plus dropped need not equal UART fan-out after disconnect discards.
- Capture before disconnect. **Do not use `broker read` for observation:** it consumes data intended for that transport. Snapshot/counter commands do not consume serial data.
## Independent web performance capture
Use physical UART0 for the least intrusive capture. Commands are also available through the authenticated admin SSH registry. `web performance enable|disable|show|clear` is separate from admission tracing (`web diagnostics enable|disable|show|clear`) and from ordinary `web counters|clear-counters`.
Performance capture defaults disabled. Enable resumes aggregates; disable freezes aggregates; clear resets aggregates while preserving enable state. Each control operation advances a nonwrapping epoch and fences in-flight samples. Exhaustion fails closed. Slot generation and broker identity also fence reuse; this is not a disconnected-session archive.
Each active one of two fixed slots shows fd, generation, broker ID, pending/measured-pending/executing state and current-epoch `pending_age_us`. Age starts at reservation-path entry and includes an executing send. If `measured_pending=0`, zero age is unavailable, not evidence of immediate service. Disabling invalidates current-epoch pending age; capture a live `show` only if investigating a stuck queue, accepting its perturbation.
Only serial **binary TX** is measured. Text/control frames are not samples, though they can delay subsequent binary work. No payloads, passwords, keys, cookies, tickets, verifier material or other authentication secrets are retained or printed.
| Printed timing | Exact interpretation |
|---|---|
| `queue->callback-entry` | Reservation-path entry to HTTPD callback entry, timestamped before the callback takes the transport lock. Includes submission-path work, not just HTTPD queue residence; owned callbacks retired without sending can count. |
| `send-call` | Time around the actual synchronous HTTPD-owner send API call. Return is not peer acknowledgement, browser receipt or rendering. |
| `completion->first-drain-attempt-return` | Successful send-call completion to return of the first subsequent broker read attempt, including empty/error attempts. |
| `completion->next-nonempty-drain-return (includes idle)` | Completion to the next successful nonempty broker read return, possibly after empty attempts and source idle time. |
| `completion->first-attempt-nonempty-return` | Subset where the first subsequent read returns data. Excludes observed empty attempts, but does not prove backlog existed at send completion. |
Each timing reports count, sum in microseconds, integer estimated average (`sum/count`, zero for no samples) and maximum. Completion intervals are not scheduler-only: they include transport work, broker read/mutex time and intervening control-frame work, and may include idle time even in the first-attempt-nonempty subset. They do not isolate a particular task, TLS operation or network cause.
`queued_frames/bytes` counts transport reservations, including reported queue failures; `queue_errors` records those failures. `sent_frames/bytes` counts successful send-call returns. `send_errors` includes owner-context rejection, whereas send-call timings require an actual API call. `retired` records measured callbacks retired without sending. Saturating aggregates set `saturated=1`; totals and averages then cease to be reliable. Epoch fencing intentionally prevents old work contaminating a new capture, so do not demand equality across toggles or in-flight clears.
## Reproducible UART0 capture
1. Record firmware/build identity, baud/framing/flow control, source burst size, browser versions, client IDs/roles and full-mix topology. Establish all serial clients and both admin routes first. Quiet the UART source and allow queues and connection activity to settle. Do not reconnect/login during the trace.
2. Optionally stop independent admission tracing before the timed capture. Then reset in this order, while quiet:
```text
web diagnostics disable
web performance clear
web performance enable
broker clear-counters
serial clear-counters
web clear-counters
```
The first command is optional; record whether admission tracing was enabled. Resets are sequential, not an atomic cross-service snapshot.
3. Send the known burst once, stop the source and wait for transport queues to drain. Avoid repeated console/status printing during the burst. A quiet final broker pending count of zero alone does not prove browser receipt; retain peer byte counts too.
4. Stop performance capture first, then capture all counters **before disconnecting any client**:
```text
web performance disable
web performance show
broker counters
serial counters
web counters
broker clients
```
Disable already prints the performance snapshot; `show` is an explicit repeatable frozen-aggregate record. Keep connection counts and source/peer totals with the sample.
5. Only after saving evidence, change to one browser, keeping the other full-mix participants and serial settings equivalent. Quiet/settle and repeat the complete reset/burst/drain/stop/capture sequence. Do not compare a fresh capture to lifetime counters.
6. Separately compare equivalent performance-enabled and performance-disabled bursts with fresh ordinary counter resets. Keep performance disabled for the latter; do not treat retained performance aggregates as that run's measurements. Keep admission tracing state equal and record it.
At 230400 baud, 8N1, 4,096 bytes represents approximately **177.8 ms** of continuous input retention from an empty broker queue, ignoring flow-control pauses. Compare per-client drops/HWM with latency maxima against this scale, not as a hard deadline or proof of cause; occupancy, idle gaps and timings cover different boundaries. For future regressions, gather per-client attribution and controlled comparisons before changing buffers, priorities, scheduling or CPU.
## Resources and regression references
Two fixed diagnostic records plus nonwrapping epoch/timestamp/slot fences and broker HWM fields add bounded storage, no new instrumentation allocations/tasks/queues/payloads. Disabled is not zero overhead: gate branches remain and broker occupancy/HWM is always active. Enabled timing and short aggregate locks have runtime costs not established by host tests. The combined-send local scratch adds HTTPD stack use; actual reserve remains a target measurement.
`tests/session_broker_diagnostics/run.py` covers counters and generation-safe management; `tests/web_serial_performance/run.py` covers production send/drain paths, epochs, retirement, SDK wire compatibility and failed-send/control-reply isolation. `tests/admin_ssh_policy/run.py` covers SSH-allowed/browser-denied tracing commands. Prior focused/broad host validation and firmware builds passed; no tests were run during documentation consolidation. These harnesses do not prove real TLS/scheduler overhead, peer-byte integrity or long-soak behavior. Use the capture procedure above for future attribution, retaining explicit workload/duration/counter provenance.
+131
View File
@@ -0,0 +1,131 @@
# Wi-Fi management-frame security update plan
Initial review date: **2026-09-16**; integration status updated **2026-09-18**. The original review was a focused documentation-only follow-up to [dependency advisory coverage](dependency_advisory_coverage.md#new-release-note-finding-wi-fi-management-frame-corruption). **At that initial review, no correction was implemented, exploit reproduced, build run, device accessed, or upgrade approved.** The fix-bearing complete IDF 5.5.3 vendor bundle is now integrated in the validated candidate; this is not radio-hardware vulnerability closure or deployment approval. See [current integration evidence](idf_candidate_integration.md#final-integration-evidence--2026-09-18) and [semantic rebase review](idf_553_rebase_review.md).
## Decision
**Prefer a complete, immutable Espressif IDF 5.5 maintenance-release bundle, integrated through a compatible pinned PlatformIO package/toolchain, over transplanting Wi-Fi archives into IDF 5.5.0.** IDF **5.5.3 is the verified fix-bearing reference candidate/minimum demonstrated release here**, not a recommendation to deploy it in preference to all later maintenance releases. The earlier coverage records 5.5.4/5.5.5; selecting a newer deployment candidate requires the same immutable provenance and compatibility gates below, not a repeat broad advisory audit.
Do not change PMF/WPA3 policy as an automatic mitigation. Do not replace only `libnet80211.a`, edit only a callback declaration, relax source guards, or overwrite the installed SDK. The vendor correction is integrated in the fresh candidate build; deployed old firmware remains exposed, and target/radio validation remains pending. PMF/WPA3 has not been weakened.
## Current applicability and trigger limits
Rechecked authoritative source `src/wifi_manager.c:315363`:
- SoftAP uses `WIFI_AUTH_WPA2_WPA3_PSK`, four clients, `pmf_cfg.capable=true`, `required=false`, `WPA3_SAE_PWE_BOTH`. AP/APSTA are real supported paths, including live fallback transitions (`set_runtime_ap_enabled`). Optional PMF is **not PMF disabled**.
- Station uses PMF capability in both profiles; mixed has WPA2 minimum/optional PMF, WPA3 requires PMF. Do not downgrade station profiles to address a demonstrated SoftAP trigger.
- Existing generated `sdkconfig.h` identifies ESP32-S3 and enables SoftAP, WPA3 SAE, SoftAP SAE, SAE H2E and SAE-PK support. This is build metadata, not proof of a running device's current mode or negotiated association.
- Architecture: the permanent Wi-Fi manager owns driver operations and bounded events; application NVS is authoritative, driver storage is RAM-only, AP policy is off/fallback/always. Working edits require explicit save. Preserve manager ownership, secret wiping and secret-free status. UART0 recovery and native USB UART1 access must remain independent of network success. Broker single-writer/multiple-isolated-observer behavior is unchanged.
The official contained fix says that when the supplicant sends an **authentication response for an already-connected station, with keys installed after a successful four-way handshake**, management encryption was selected from `bss->pmf_enable` without checking whether the frame was robust. The encryption code assumed header space that had not been allocated, causing memory corruption. The fix checks robust-management-frame status before encryption.
This supports a current-feature exposure candidate when SoftAP is active with the relevant station/key/PMF state. Radio delivery of the relevant management exchange, rather than application HTTPS/SSH authentication, is the pertinent access path (inference from the described Wi-Fi operation). The public text does **not** establish whether an attacker must know the PSK, personally complete association, or can spoof an existing station's exchange. It supplies no packet-level proof, CVSS, reliable arbitrary-code-execution claim, or exhaustive affected-mode matrix. Do not characterize this as proven unauthenticated remote exploitation, nor assume application login protects it. STA-only exclusion is limited to the described AP-response path, not a blanket clean bill for the opaque bundle.
The same vendor change also fixes an RSN-IE length/allocation mismatch, deauthentication-reason parsing, and default SAE PWE values. The project's short configured IE set has not been shown to trigger the allocation issue; do not conflate that uncertainty with the established old ABI/bundle identity.
## Official immutable provenance
Official sources fetched in this review:
1. [5.5.3 release](https://github.com/espressif/esp-idf/releases/tag/v5.5.3), also [release API](https://api.github.com/repos/espressif/esp-idf/releases/tags/v5.5.3): explicitly names the management-frame memory-corruption correction under `a3927641`.
2. [Merge commit](https://github.com/espressif/esp-idf/commit/a3927641bfd0aedfaab3514cba3d7fb01378ce4d), checked through its official commit API: **`a3927641bfd0aedfaab3514cba3d7fb01378ce4d`**. Its title emphasizes SAE defaults; the title alone omits the security detail.
3. [Contained fix patch](https://github.com/espressif/esp-idf/commit/caf4cad3b422be5c2f93afeff4cd07796a262b46.patch): **`caf4cad3b422be5c2f93afeff4cd07796a262b46`**, “Backport some wifi fixes,” with the trigger explanation. This is the merge's second parent, not a conflicting identification.
4. [Annotated release tag object](https://api.github.com/repos/espressif/esp-idf/git/tags/b31fcc7a314a44ad992b58f589f7d1d8a4fadff6): tag object **`b31fcc7a314a44ad992b58f589f7d1d8a4fadff6`** resolves to IDF commit **`2c211b236707889e8400c4dc5644dd5c4ee071e0`**. [Immutable comparison](https://api.github.com/repos/espressif/esp-idf/compare/a3927641bfd0aedfaab3514cba3d7fb01378ce4d...2c211b236707889e8400c4dc5644dd5c4ee071e0) reports ahead 195 / behind 0, establishing inclusion of the merge.
### Exact published source/ABI delta
The fix changes four paths:
| Path relative to IDF | Published change |
| --- | --- |
| `components/wpa_supplicant/esp_supplicant/src/esp_wifi_driver.h` | `wpa_funcs.wpa_ap_get_wpa_ie`: `uint8_t *(*)(uint8_t *len)` becomes `uint8_t *(*)(size_t *len)` |
| `components/wpa_supplicant/esp_supplicant/src/esp_wpa_main.c` | Matching callback implementation takes `size_t *ie_len`; assignment from `hapd->wpa_auth->wpa_ie_len` remains |
| `components/esp_wifi/lib` | Submodule `d7373a90dc3f0be841b29911e33de5f99988dbff`**`a4e903fe43bf09a95022f9802db43d39740ccc0b`** |
| `components/esp_wifi/include/esp_wifi_types_generic.h` | Two SAE PWE documentation comments record default `WPA3_SAE_PWE_BOTH`; not structure-layout changes |
The historical shared IDF 5.5.0 header and callback take `uint8_t *`; `esp_wpa_main.c` registers the callback for SoftAP. The callback slot/pointer size need not change for this to be an ABI break: the width and interpretation of the pointed-to output change (one byte versus a `size_t`, four bytes on ESP32-S3). Mixed old/new caller and callee can corrupt storage or leave an incorrect length. Casting the callback does not fix the contract. Both sides, including the opaque caller, must agree.
The visible C edits are the length correction/interface marker. **The management-encryption fix is in the vendor binary update, not a public C hunk that can be copied into this application.**
### Bundle identity: baseline, fix point, release point are different
- Historical shared installed baseline matches Wi-Fi submodule **`8a1b7bbc00e895d040c5c9a6fb9d1db2bbfc7958`** from IDF v5.5, not the fix's immediate predecessor `d7373a90…`.
- The exact fix-point bundle is **`a4e903fe43bf09a95022f9802db43d39740ccc0b`**.
- The complete 5.5.3 release points to **`e0befaa593277b4e80726079fbd521b4681754c2`**, verified again at the immutable release commit. [Wi-Fi comparison](https://api.github.com/repos/espressif/esp32-wifi-lib/compare/a4e903fe43bf09a95022f9802db43d39740ccc0b...e0befaa593277b4e80726079fbd521b4681754c2) reports ahead 3 / behind 0. Do not call the fix-point bundle the final 5.5.3 bundle.
Calculated Git blob SHA-1 (`blob <length>\0` plus bytes) of **all seven installed S3 archives** and compared against official metadata for [baseline](https://api.github.com/repos/espressif/esp32-wifi-lib/contents/esp32s3?ref=8a1b7bbc00e895d040c5c9a6fb9d1db2bbfc7958), [fix point](https://api.github.com/repos/espressif/esp32-wifi-lib/contents/esp32s3?ref=a4e903fe43bf09a95022f9802db43d39740ccc0b), and [release point](https://api.github.com/repos/espressif/esp32-wifi-lib/contents/esp32s3?ref=e0befaa593277b4e80726079fbd521b4681754c2):
| Archive | Installed = baseline Git blob | Fix-point Git blob | 5.5.3-release Git blob |
| --- | --- | --- | --- |
| `libcore.a` | `ec2e76caad4c29ee92ec45432e0dc3bf946ea4c7` | `af63b0f8254bde084b2f474cdba1c1d63b4ae750` | `9f7b14a8bf6eec64973da8adc65d35b5ba9bee49` |
| `libespnow.a` | `d4925dc7f73173077e8ed58abbbe5a59b5bb7be5` | `da0537c3f754a9d638fbecd4f4d806bfa66316b4` | `132b4f67e339ca2081d2add91c14eefa39476ff9` |
| `libmesh.a` | `5cd43bf5a0a0388fb6f093a154401f00227d9570` | `6ae30c90ba59ab949e98db89b6b51daab434a5a0` | `2e9dc1c8c7afbf033337b4175032e9b1161e3262` |
| `libnet80211.a` | `6ee20bec142638dc0a442b9642de9fa62db6ddd0` | `8ce8a2a6a9a753e31508bd609bf0a0d1d44c332f` | `2800d447ec385d33869373696ba8191292647694` |
| `libpp.a` | `c9fc98fe529c9ff2891839fdb9a41b1189c03c49` | `536fd25e30a1be76ff122d93e7481f1da22e6892` | `8944bcad7371621045f376cf74c62fde6f368cbb` |
| `libsmartconfig.a` | `31518eb9336f07de14b4a71c0389217a54ed3ec7` | `19e8274e0961fdf88351a0dec6e0d696b8ee2b94` | `4dc759e25617aa00b9e12887fd092a1d5780a170` |
| `libwapi.a` | `0604ff76aa9134b475daf08496e03350d5ef8f09` | `e43863a22e93990b8a4deb7c4efc8e4c27566789` | `65655b6feab0572a6e8a1200946d53a21f3f4722` |
All historical shared-SDK archives matched baseline; all differ from both newer bundles. This is artifact identity, not proof that every archive is linked or contains the defect. Remote binaries were **not downloaded during the initial review**. Later candidate archive download/verification and complete Wi-Fi/PHY/coexistence/supplicant package comparisons are recorded in the integration/rebase reviews; the candidate uses the release-point column, not this old baseline. Git IDs are provenance identifiers, not independently measured remote SHA-256 digests or a vendor signature verification.
The release API advertises the submodule-inclusive `esp-idf-v5.5.3.zip`, asset ID **357881088**, SHA-256 **`d16e6b57b1df1a523aff6a1d0ef2f99cf058cb71e73f694429769eaaea171428`**. This is vendor-published metadata, not a locally verified download. The release is marked `immutable=false`; therefore pin the resolved commit/submodules and verify artifact hashes, not merely a tag URL. Espressif warns that GitHub's automatic source archives omit submodules; those are not complete SDK inputs.
## PMF/WPA3 mitigation assessment
Both the [v5.5 documentation source](https://raw.githubusercontent.com/espressif/esp-idf/v5.5/docs/en/api-guides/wifi-security.rst) and [immutable candidate documentation](https://raw.githubusercontent.com/espressif/esp-idf/2c211b236707889e8400c4dc5644dd5c4ee071e0/docs/en/api-guides/wifi-security.rst), plus the installed type header, establish:
- `pmf_cfg.capable` is deprecated, effectively true internally. Setting it false is **not a supported PMF-off mitigation**.
- `required=false` permits optional PMF; it does not disable it.
- `esp_wifi_disable_pmf_config()` is the documented optional-PMF disable API. Disabling PMF on WPA3 or WPA2/WPA3 mixed SoftAP returns an error.
- WPA3 mandates PMF. WPA2-only mode by itself still does not prove PMF is disabled.
**No issue-specific official workaround was found in the inspected release/fix text.** The API documentation explains general configuration, not an endorsement that disabling PMF safely mitigates all defects in this change. The trigger supplies a rationale that actually eliminating `bss->pmf_enable` could avoid the described encryption path; the opaque implementation and lifecycle have not been proved to honor that invariant across existing associations/restarts.
Consequently, a WPA2-only plus explicit PMF-disable implementation is at most an **unapproved conditional workaround candidate**. It would remove SAE benefits and management-frame protection, require explicit owner acceptance, documented threat trade-offs, correct manager-owned stop/reconfigure/start sequencing, checked API results, and target evidence that negotiated PMF cannot reappear across APSTA/fallback transitions. It would not repair the RSN-IE allocation defect. Do not implement it as a silent downgrade, global WPA3 Kconfig removal, or station-profile change.
If operational containment is urgently required before an update, the owner may explicitly choose existing AP-off policy (removes the described AP response path) or Wi-Fi-off (removes radio service), accepting lost network/fallback access. These are feature-withdrawal containment options, **not vendor-certified workarounds or a source fix**. Preserve and verify UART0/native USB access first; do not automatically persist policy changes. No containment action was taken here.
## What a coherent replacement entails
### Preferred: complete vendor maintenance release
Use all IDF source, private/public headers, Wi-Fi/PHY/coexistence libraries and other submodules from one resolved release, with its toolchain and Python/build requirements. Do not install final-release blobs alongside fix-point-only C edits or mix two release revisions. Preserve the application security policies separately through audited overrides.
Current integration disposition (supplied parent evidence, 2026-09-18):
| Input | Current state | Remaining boundary |
| --- | --- | --- |
| Root `platformio.ini` | Platform 6.13.0, framework 3.50503.0 / IDF 5.5.3, Xtensa and RISC-V 14.2.0+20251107 | Exact version pins, not complete immutable root dependency closure; default root build timed out during installation at 200 seconds before compilation |
| Isolated `app-validated` | Fresh build PASS; 95,552 B RAM / 1,749,493 B flash (+1,212 / 19,408 B versus historical 94,340 / 1,768,901 B) | Linked size is not runtime reserve |
| Protected corrections | Nine C sources plus forced header; per-entry semantic rebase, five new signed WS receive checks; 982 cases / 10 mutation checks | No original correction removed as superseded; retain exact fail-closed guards |
| Actual build/host validation | Explicit candidate SDK/core/build with interop and web performance PASS 24/24; source equality 3,237 files, identical pre/post hash recorded in [validation](phase9_validation.md#current-candidate-execution--2026-09-18) | Not radio-hardware vulnerability closure or full target acceptance |
| Radio delivery | All packaged Wi-Fi/PHY/coexistence/supplicant files match locked framework archive; candidate callback is `size_t *` | Not independent opaque-code correctness or recursive upstream submodule provenance |
| Notices | Nine entries semantically rebased, 66 unchanged, 75 total; 36 fixtures PASS; two actual bundles each 77 files / 4,433,930 bytes | Recipient/source/legal clearance pending |
The completed [semantic rebase review](idf_553_rebase_review.md#3-per-entry-semantic-disposition) covers candidate originals, not only Wi-Fi: HTTPS cleanup/private-key release, HTTPD scratch/null-read handling, server-local TLS policy, DHCP bounds, TLS EMS failure, X.509 allocation failure; retain/revalidate wolfSSH password/parser/ordering/ABI changes and wolfCrypt settings. Mark each correction **retained, rebased, or superseded with source/test evidence**. Upstream fixes do not automatically supersede local lifetime/policy additions in the same source. Also retain silent-panic/no-coredump and heap-wipe policy guards. The durable architecture's older four-correction summary is not the complete current inventory; the current generator and code map are authoritative.
### Contingency: vendor-supported backport onto 5.5.0
The smallest **published delta against the fix's own parent** is the four-path change above. The smallest **proven safe delta against this project's 5.5.0** is **not established**.
A candidate backport must at least pair both callback changes with one complete ESP32-S3 Wi-Fi archive set from the same vendor revision, retain the vendor patch/provenance, and establish compatibility with all private Wi-Fi headers, OS adapter tables, supplicant structures/callbacks, PHY/coexistence interfaces, configuration ABI and toolchain. All seven S3 archives form the conservative replacement set; do not mix old optional archives with new ones simply because current linking omits them. This minimum set is necessary for coherent provenance, **not sufficient proof** of cross-baseline compatibility.
Ask Espressif for a supported 5.5.0 backport bundle or an explicit compatibility statement with its required source/header/library/toolchain prerequisites, confirming both management-frame and IE-length corrections. The immediate-parent blob pointer differs from baseline; successful linking alone cannot establish binary layout/calling-contract compatibility. Without that evidence or a complete audited dependency closure, choose the full-release path rather than treating the two C signature edits plus blobs as approved.
## Implementation plan history and remaining acceptance
Steps 14 below preserve the original plan: package selection, isolated preparation, override rebase and fresh candidate build/host checks now have the evidence above. Complete immutable dependency closure is not claimed. Steps 56 remain acceptance/release gates; historical “no code change yet” language is not a new blocker.
1. **Compatibility/provenance gate first, no code change yet.** Resolve one deployment release and PlatformIO delivery combination. Record immutable SDK commit, recursive submodule pins, package/toolchain identities and SHA-256 manifests. Confirm vendor inclusion of the fix. Obtain missing packaging/ABI evidence; if unavailable, report the blocker instead of guessing a framework URL or transplanting archives.
2. **Prepare isolated inputs after approval.** Do not modify the user's shared installed SDK. Obtain complete vendor inputs in a dedicated location, verify provenance/hashes, and compare only relevant integration surfaces and protected-source entries. Keep existing managed components pinned where compatible; explicitly review any resolver-required changes. Review license/notice/source-delivery updates for new radio/SDK artifacts.
3. **Rebase security contracts.** Implement a reviewed per-entry disposition and exact new source pins, retain fail-closed guards, and verify every generated override/forced header reaches the actual targets. Add a bundle-consistency check for the selected SDK and archive identities so an old/new mixed bundle fails deterministically. Do not enable unrelated features, regenerate web assets, migrate credentials, or alter partitions.
4. **Build/host gates in the later implementation task only.** Clean candidate build with the selected toolchain; inspect linked archive paths/map, generated configuration and actual compilation inputs. Run SDK override, crypto, parser, ordering/interop, authentication/protocol, build-policy and notice validations as applicable to the resolved input changes. Record sizes and compare resource budgets; a host pass is not radio validation. Existing historical passes do not validate this candidate.
5. **Target gates under explicit device authorization.** Exercise WPA2 with/without negotiated PMF and WPA3 SoftAP; authenticate/reassociate after keys are installed; cover PMF optional/required behavior, four clients, disconnect/reconnect and AP/STA/APSTA/fallback transitions. Obtain vendor regression guidance or an authorized isolated-radio reproduction for the exact trigger; ordinary reconnect tests alone do not prove vulnerability closure. Stress concurrent HTTPS/WebSocket/SSH and verify bounded recovery, heap/stack reserve, queue behavior, no secret/crash-memory disclosure, and unchanged broker isolation. Verify UART0 recovery and native USB UART1 operation while Wi-Fi fails/stops/restarts. Include persisted-config reboot tests without erasure.
6. **Release gate.** Keep PMF/WPA3 security at least unchanged. Record exact artifacts and test evidence, retain a controlled recovery image and configuration backup procedure, and mark the old image as still exposed rather than calling rollback a security fix. No flash erase, upload, pin changes or deployment until separately authorized. Update durable project memory only when implemented contracts/ownership genuinely change.
## Open evidence and review boundary
- **Resolved:** official fix IDs/description; exact visible ABI delta; all seven installed S3 archives match baseline; distinct fix and final-release bundles; release/fix ancestry; current application configuration; PMF-disable restrictions; exact-IDF/hash guards; recommended versus installed toolchain mismatch.
- **Current resolved integration:** official PlatformIO 6.13.0 / IDF 5.5.3 delivery and exact toolchain selection, semantic override rebase, fresh candidate build and 24/24 host validation, whole packaged radio-component equality and notice rebase.
- **Still unresolved:** complete immutable ancillary/tool/Python/managed dependency closure; independent PHY/coexistence upstream submodule provenance; opaque binary implementation correctness; packet-level attacker requirements; target/runtime/resource evidence and deployment acceptance. A 5.5.0 transplant remains unproved and was not selected.
- A follow-up GitHub API request for candidate `components/esp_phy/lib` returned **HTTP 403 rate limit exceeded**. The sequential metadata query stopped there, so PHY/coexistence pins were not established; no inference was drawn from that failure. Already completed Wi-Fi/release/ancestry queries succeeded. Official raw documentation remained available. Do not treat this partial metadata boundary as a compatibility clearance.
- Historical initial-review validation only: read-only local source/package/config inspection; local seven-archive Git-blob hashing; official patch/release/tag/content/comparison/toolchain/documentation reads. No broad repeated dependency audit, binary download, PlatformIO invocation, build, test suite, device operation, configuration edit, or asset generation. Documentation links/provenance do not constitute signed-vendor attestation, exhaustive security certification, or Phase 9 sign-off.
+192
View File
@@ -0,0 +1,192 @@
# Phase 9D — wolfSSL / wolfSSH advisory review (partial)
## Implementation addendum — 2026-09-16
**Xtensa small-math mitigation implemented; the broader review and security sign-off remain open.** Root `CMakeLists.txt` defines `CURVE25519_SMALL` and `ED25519_SMALL` before component parsing. `cmake/wolf_crypto_policy.cmake` propagates a forced-include resolved-settings guard PUBLIC from wolfSSL to consumers, including wolfSSH/application code. `cmake/wolf_crypto_policy.h` requires both enabled small implementations, rejects `WOLFSSL_CURVE25519_BLINDING`, and rejects enabling Curve448/Ed448 without review. This follows PR 9275's small-math policy for the enabled Xtensa algorithms, with consistent library/consumer production flags and ABI-sensitive layouts. **Blinding is not enabled with small X25519**: wolfSSL 5.8.2 excludes/rejects this combination; do not force it back on or present the historical blinding observation below as current policy.
**Parser corrections implemented:** `tools/security_overrides.py` extends the existing hash-pinned wolfSSH generated source (now eight C source overrides plus one header overall). The PR892 subset bounds `DoIgnore`/`GetSkip` and service-string reads, accepts boundary-empty skips, rejects zero-capacity `GetString`, and preserves the old strict service-name length limit. The PR881 subset rejects channel-window addition overflow without changing the window. The PR880 subset changes both ECC/Ed25519 key/signature label comparisons to short-circuit OR. Local framing corrections bound ECC r/s reads to the declared sub-blob and require exact inner/outer consumption; Ed25519 also requires exact outer consumption. Valid framing and crypto/digest/message construction remain unchanged; formerly tolerated malformed labels/trailing bytes reject. Existing password bounds/wiping and async-pending retention remain intact. See the [parser contract and deferred scope](../tests/wolfssh_parser_contract/README.md); these are subsets, not complete PR backports. The [finite remaining SSH review](ssh_parser_remaining_review.md) is complete: bounded exact CHANNEL_FAILURE recipient parsing (fatal policy retained), exact `ssh-userauth` service validation, and exact length/byte dispatch for all nine channel-request names are implemented. Unknown-request/trailing-payload behavior is preserved. PR899 client key skips are unchanged and blocked by current server role/ordering; PR918/919 forwarding is disabled. The inspected generic signature caller trace found no attacker-selected short-digest/OID path; generic APIs remain unpatched. These are profile-specific dispositions, not exhaustive parser/library safety; revisit on caller, feature, KEX or source changes.
**Crypto validation implemented:** PUBLIC `WOLFSSL_VALIDATE_ECC_IMPORT` and `WOLFSSL_ECDHX_SHARED_NOT_ZERO` enable existing upstream P-256 import validation and X25519 all-zero-result rejection. The guard rejects missing checks and reviewed validator-disabling/hardware-stub configurations. Strict production checks confirm the effective flags without candidate injection. The [key-validation review](ssh_key_validation_review.md) traces the previously missing unauthenticated P-256 KEX point check and low-order X25519 inputs that pass the old precheck. Current raw SSH signature callers supply locally derived full digests; no current short-digest/OID-confusion trigger was found. This does not backport generic PR10131 API hardening or demonstrate an exploit. Import validation adds CPU/allocation cost requiring target measurement.
Supplied parent build evidence: `pio run` **PASS**, **94,340 B linked RAM / 1,768,901 B flash**, unchanged RAM / **+200 B flash** versus 1,768,701 B. Final parent results: **all seven suites PASS** — ordering `--interop` (8,028 checks, seven rejected mutations, 12 exact-256-KiB sessions with clean channel close/transport EOF), SDK overrides with actual `--build-dir` registration, auth (135 cases), protocol, strict crypto, notices (30), and parser (3,258 cases × two modes plus channel profiles; 11 + 18 + 2 rejected mutations). Interop's unsandboxed approval was solely for local AF_UNIX sockets; no remote network or device operation. See [dated evidence and limits](security_hardening.md#ordering-hostbuild-evidence--2026-09-16). These are supplied results, not build/test reruns by this documentation update or runtime-headroom evidence.
**Restricted ordering correction implemented for CVE-2025-14942; not a full upstream backport or advisory/phase sign-off.** The finite parser/client/generic-caller questions are dispositioned in the linked report; broader advisory coverage, whole-phase target validation and [release delivery/source/legal gates](release_packaging.md#separate-work-before-distribution) remain open. Offline notice assembly is implemented, not distribution clearance. No dependency upgrade or device operation was performed. Phase 9 is not complete or production-ready.
### Restricted ordering correction and remaining gates
The earlier temporary attempt is superseded by the [implemented restricted-profile correction and prerequisite disposition](../tools/wolfssh_order/README.md). wolfSSH remains pinned to **1.4.20** with the existing **Curve25519-SHA256 / ECDH-P256** KEX profile; DH/GEX/PQ widening is rejected. Independent SELF/PEER keying bits, exact nonzero expected-message tracking and service/authentication phase gates reject wrong-direction, premature and duplicate traffic. Receive expectations are separate from send-side rekey fences. Pre-peer-KEXINIT in-flight traffic remains legal when only SELF initiated rekey.
`SendNewKeys` queues one packet under the old sending keys and installs new sending keys; SUCCESS or WANT_WRITE clears SELF, retaining PEER and its expected NEWKEYS. Existing flush paths finish the same buffered packet without resending NEWKEYS. **EXT_INFO is intentionally disabled**, rather than adding a pending extension continuation: no `ext-info-c`, no outgoing EXT_INFO, incoming EXT_INFO rejected, `sendExtInfo`/`extInfoSent` remain zero. There is **no `server-sig-algs` advertisement** now; the KeyAccepted setter does not produce that wire extension. RSA-SHA2 discovery/RSA user-key interoperability is not claimed.
The executable consolidated edits are [delta.json](../tools/wolfssh_order/delta.json), not all hunks of PR793/819/840/855/921. [provenance.json](../tools/wolfssh_order/provenance.json) records exact archived patch byte hashes, source URLs and full embedded commit IDs; preserve those archives rather than relying on mutable PR responses. Original source/header hashes are pinned, upstream notices retained, and generated wolfSSH files carry the **2026-09-16** ordering modification/provenance notice. There are **eight C overrides plus one generated `wolfssh/internal.h`**. CMake propagates the ABI overlay BEFORE PUBLIC and as a PUBLIC forced include, with a stale-header guard and configure/compiler dependencies. Joined `-include/path` arguments preserve both ordering and crypto forced headers through PlatformIO sorting/deduplication; the adapter regression exercises a real Xtensa consumer and a failing split-option mutation.
Review found a misplaced EOF guard; it is now corrected in `SendChannelEof` before lookup/serialization/`eofTxd` mutation. The extra `SendChannelExit` guard remains intentional local hardening, not PR793's EOF hunk. Follow-up verification found no scoped blocker. Supplied ordering evidence: **8,028 checks and seven rejected mutations**, including actual EOF/shutdown/exit-status rekey fences.
The initial host interoperability run failed a close race despite printing `INTEROP PASS` early; that output was not successful completion. The corrected harness waits for peer channel close and transport EOF, uses a local fd-passing proxy, independently owns/reaps the server, and requires both process exits to be zero. Final agent evidence: **36/36 repeated OpenSSH 10.2p1 sessions**, each exact **256 KiB** binary echo, with **ten** completed exchanges for client-rekey cases or **two** for fragmented server-rekey cases, clean exits and no EXT_INFO. See the [test contract, harness diagnosis and limits](../tests/wolfssh_order_contract/README.md). This establishes bounded host interoperability, not general shutdown correctness or device behavior.
Official registry queries for wolfSSH **1.5.0** and wolfSSL **5.9.2** returned **404 on 2026-09-15**. Upstream tags exist at wolfSSH commit `8643d7be841184f766374e3b0ed68ced6391543c` and wolfSSL commit `ac01707f552c611fbd135cc723b2682b3e7f80f2`; tag existence is not managed-component availability or ESP compatibility. This is supplied query evidence, not a fresh network check by this documentation update.
Remaining gates: target cleanup/disconnect during rekey, no-EXT_INFO compatibility with intended clients, initial KEX/rekey under load, and stack/heap/timing measurements during whole-phase validation. Broader advisory coverage and release-specific source/legal/recipient delivery remain open; finite parser review and notice assembly are no longer pending implementation. Any future upstream upgrade still needs isolated packaging/compatibility evaluation, coherent source/header review and explicit rebasing of source overrides and version/callback contracts while preserving password/parser/crypto protections. The restricted correction is not a full wolfSSH 1.4.22/1.5.0 backport, dependency upgrade, or phase sign-off.
## Historical pre-mitigation research baseline — 2026-09-15
**The remainder retains the original pre-mitigation research evidence. “Current” macros, generated hashes, priorities and “not applied/tested” statements below describe that historical snapshot, not today's implementation. The addendum and linked key-validation review supersede the scoped small-math, parser, ECC/X25519 and ordering status claims. Restricted ordering is implemented; its target/compatibility gates remain open; the [finite remaining parser review](ssh_parser_remaining_review.md) supersedes historical deferred PR899/service/client/generic-caller questions, without certifying dormant dependency code.**
Review date: **2026-09-15**. Read-only external research and local applicability inspection; stopped at the user's requested handoff. **Not a completed security review or release clearance.** Only this new report was written. No dependencies, sources, generated inputs, or parent documents were changed; no build, hardware test, network exploit, or public PoC was executed.
## Release decision / current-path priorities
1. **Block security sign-off pending mitigation of CVE-2025-12888.** Current SSH advertises X25519 on Xtensa, and actual compiler preprocessing confirms the affected non-small implementation. Upstream recommends the small implementation; existing blinding is not evidence that this separate compiler/architecture issue is resolved. Exploitability on this precise GCC/optimization/device combination was not measured.
2. **Block sign-off pending a reviewed wolfSSH message-order correction.** CVE-2025-14942 is critical for clients; upstream explicitly recommends its fix for servers because the same defect exists, without identifying a specific server attack. The generated local server state machine lacks the fix. Do **not** report demonstrated server password disclosure or authentication bypass from this evidence.
3. **Close current-path parser gaps before sign-off.** Upstream wolfSSH PR 892 fixes unchecked SSH_IGNORE/service-string parsing still present in the generated source. These are security-relevant malformed-input handling defects, not assigned CVEs in the inspected release notes. Actual out-of-allocation access/exploit impact remains unproven: packet padding and enclosing receive-buffer invariants need auditing. Phase 9C's password fix does not fix these handlers.
4. **Keep ECC validation and other non-CVE hardening open.** Current ECC import lacks `WOLFSSL_VALIDATE_ECC_IMPORT`; 5.9.1 release notes explicitly recommend this flag for older versions. Whether the SSH ECDH path already performs equivalent validation before scalar multiplication was not resolved. Do not call this a confirmed invalid-curve vulnerability.
Smallest proposed mitigation for item 1: enable `CURVE25519_SMALL` consistently for library and consumers through project-owned compile policy; upstream PR 9275 also enables `ED25519_SMALL`, `CURVE448_SMALL`, and `ED448_SMALL` on Xtensa. For enabled algorithms, follow that upstream policy rather than assume X25519 blinding suffices. Check the effective blinding/small combination, ABI, compile guards, performance, and real SSH interoperability. This was **not applied or tested**.
Item 2 is **not a safe one-line patch**. PR 855 changes `internal.c`, handshake layout in `wolfssh/internal.h`, message ranges, expected-message tracking, and logging; 1.4.20 also predates intervening rekey changes. A backport needs the coherent library/header change set, audit of prerequisites, and later PR 921 follow-up noted in 1.5.0. Prefer evaluating **wolfSSH 1.5.0 + wolfSSL 5.9.2**, the newest stable releases returned by the queried APIs, rather than merely reaching the minimum fixed release. This is a candidate pair, **not verified ESP managed-component availability or compatibility**. Any upgrade requires rebasing exact-hash Phase 9C overrides, auth/version guards, callback changes (including keyboard auth), and contract tests; do not blindly repin.
For item 3 the smallest candidate backport is the bounded `GetSkip`/`GetString` handling from PR 892, including necessary helper semantics, plus regression cases for truncated/oversized/wrapping lengths. Preserve Phase 9C password framing and wiping. No backport has been proven safe by this report.
## Local evidence and effective policy
- `dependencies.lock`: `wolfssl/wolfssl` **5.8.2~1** (upstream **5.8.2**), component hash `4d619e882c19d967bbaa53302e3bd2bdb8c611b5efb13302171aace809fccfdf`; `wolfssl/wolfssh` **1.4.20**, component hash `24d623360c07374a90b1ade8d1218b24bb7b661fd51ace9135dccf510a338927`.
- Actual database: `.pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json`. Replayed four actual compiler commands with `-E -dM`, removing compilation/output/dependency-write flags, capturing stdout only: wolfCrypt `ecc.c`, `curve25519.c`, `random.c`, and generated wolfSSH `security_overrides/wolfssh_internal/internal.c`. All four preprocessing invocations returned 0. This is current compile-policy evidence, not proof the flashed binary matches it.
- Compiler: `xtensa-esp32s3-elf-gcc`, `__GNUC__=14`, `__GNUC_MINOR__=2`, `__XTENSA__=1`; representative wolfCrypt commands use **`-Og`**.
- Generated wolfSSH input SHA-256: `1c04d26d46f7bef61eefcf0b78c43742662001bf6054ad2383500c9063c62494`. `tools/security_overrides.py` pins original `internal.c` SHA-256 `81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9`; edits bound both password lengths with `GetSize`, prevent malformed new-password framing reaching the callback, and wipe the checked method suffix. No message-order or IGNORE/service parsing correction appears in those edits.
- `src/ssh_protocol_policy.c`: KEX `curve25519-sha256,ecdh-sha2-nistp256`; host key `ecdsa-sha2-nistp256`; ciphers AES-128/256-GCM; MAC advertisement HMAC-SHA256; user-key advertisement Ed25519/P256. Database authorization remains separate from advertisement.
- `src/ssh_transport.c`: creates `WOLFSSH_ENDPOINT_SERVER`; sets authentication callbacks, explicit keyboard rejection, shell acceptance, and exec/subsystem rejection. Project memory identifies wolfCrypt as SSH crypto, **not the HTTPS provider**. However, **`WOLFCRYPT_ONLY` and `NO_TLS` are absent** in the actual macros: “used only for crypto” must not be mislabeled “TLS compiled out.” No duplicate IDF/mbedTLS advisory assessment is made here.
| Area | Effective observations | Consequence |
| --- | --- | --- |
| Math | `USE_FAST_MATH` present; `WOLFSSL_SP_MATH`, `WOLFSSL_SP_MATH_ALL`, `WOLFSSL_HAVE_SP_ECC`, `WOLFSSL_HAVE_SP_DH`, `WOLFSSL_SP_NONBLOCK` absent | TFM, not SP. SP-specific release fixes are not automatically current-path findings. |
| DH | `NO_DH` present; generated SSH also has `WOLFSSH_NO_DH` | Finite-field DH and SSH DH-GEX fixes are not current negotiated features. ECDH is distinct and remains enabled. |
| ECC | `HAVE_ECC`, `ECC_TIMING_RESISTANT`, `HAVE_ECC_CHECK_KEY` present; `WOLFSSL_VALIDATE_ECC_IMPORT`, `USE_ECC_B_PARAM` absent; `HAVE_ECC_CHECK_PUBKEY_ORDER` appears in `ecc.c` after its internal includes | A compiled key-check implementation is not proof callers invoke it. `ecc.c:1099310996` gates import-time checking on the absent validation flag. |
| X25519 / Ed25519 | `HAVE_CURVE25519`, `HAVE_ED25519`, `WOLFSSL_CURVE25519_BLINDING` present; `CURVE25519_SMALL`, `ED25519_SMALL` absent | CVE-2025-12888 configuration matches; earlier blinding hardening is enabled. |
| RNG | `HAVE_HASHDRBG`, `WC_RNG_SEED_CB` present; custom generate-block/seed macros absent; `wc_SetSeed_Cb(ssh_seed)` registration observed | Project callback supplies wolfCrypt Hash-DRBG seeding. Do not confuse this with OpenSSL RAND-after-fork or claim entropy quality was tested. |
| AES / hardware | `HAVE_AESGCM` present; streaming GCM absent; ESP32 crypto port present, hardware AES/hash explicitly disabled; RSA hardware-disable macro absent | Current AES/hash software policy is intentional. Not an all-software-crypto claim. |
| Optional APIs | `HAVE_PKCS7`, `HAVE_ECCSI`, `HAVE_CHACHA`, `HAVE_XCHACHA`, `HAVE_POLY1305`, `OPENSSL_EXTRA` absent; ASN template present/original absent | Many crypto-only advisories still need feature/API filtering, not just TLS exclusion. |
| SSH features | `WOLFSSH_CERTS`, `WOLFSSH_SFTP`, `WOLFSSH_SCP`, `WOLFSSH_FWD`, `WOLFSSH_AGENT` absent; `NO_WOLFSSH_CLIENT` absent | No certificate/file-transfer/forwarding feature; client code not compile-disabled, but inspected application is server-only. |
## Priority advisory evidence table
Dates below are CVE record **publication dates (UTC)**, not reservation or release dates. Upstream severity does not by itself establish local exploitability. Source URLs are listed below.
| ID / upstream severity | Published | Affected / fixed upstream | Local applicability and evidence |
| --- | --- | --- | --- |
| **CVE-2025-12888 / Low** | 2025-11-21 | `<5.8.4`; fixed 5.8.4, wolfSSL PR 9275 | **Applicable configuration, immediate mitigation priority.** Xtensa + advertised X25519 + small implementation absent. Patch inspected. |
| **CVE-2025-14942 / Critical (client)** | 2026-01-06 | `<1.4.22`; fixed 1.4.22, wolfSSH PR 855 | **Potential server impact / upstream recommends server fix.** Generated `internal.c:587623` has old accept-state filtering, no expected-message tracking. Critical client credential-leak scenario is not this application's role. |
| CVE-2025-11625 / Critical | 2025-10-21 | `<=1.4.20` client; fixed 1.4.21, PR 840 | **Not current role.** Host-verification bypass/credential leak concerns wolfSSH clients. CNA version string is malformed (`1.4.20;0`); range taken from official release notes. |
| CVE-2025-11624 / Medium | 2025-10-21 | 1.3.01.4.20 inclusive; fixed 1.4.21, PR 834 | **Not current feature.** SFTP file-handle stack overflow; SFTP absent. |
| CVE-2025-15382 / Medium | 2026-01-06 | Release notes: 1.4.121.4.21 inclusive; fixed 1.4.22, PR 859 | **Not current feature.** SCP clean-path overread. CNA incorrectly/differently says `<1.4.21`; use conservative release-note range and retain discrepancy. |
| CVE-2026-0930 / Low | 2026-04-20 | 1.4.15`<1.5.0`; fixed 1.5.0 | **Not current platform/application.** Windows wolfSSHd terminal resize. Release notes name PR 864; CNA references PR 846 instead. Discrepancy not resolved. |
| CVE-2026-5194 / Critical | 2026-04-09 | 3.12.0`<5.9.1`; fixed 5.9.1, wolfSSL PR 10131 | **Not demonstrated on current SSH flow.** Certificate OID/type confusion absent without SSH certificates. Patch also hardens low-level ECC digest lengths, so crypto-only use is not a blanket exclusion. Inspected SSH paths derive digest sizes from hash IDs; finish tracing every current raw-signature call before closure. |
| CVE-2026-5187 / Low | 2026-04-09 | `<=5.9.0`; fixed 5.9.1, PR 10025 | **No current trigger established.** `DecodeObjectId`/unknown ASN.1 extension handling; raw SSH keys are not X.509 extensions. ASN is compiled for keys/signatures, so retain API reachability check rather than claim all ASN parsing absent. |
| CVE-2025-13912 / Low | 2025-12-11 | `<5.8.4`; fixed 5.8.4, PR 9148 | **Not reported compiler/architecture combination.** Advisory describes Clang on AArch64/RISC-V/x86_64; current GCC14.2 Xtensa. Separate from CVE-2025-12888. |
| CVE-2025-7396 / Low | 2025-07-18 | Blinding introduced 5.8.0, enabled by default in applicable 5.8.2 builds | **Existing mitigation present:** actual `WOLFSSL_CURVE25519_BLINDING`. CNA affected string `5.8.0; 0` is not a usable precise range. Not a resolution of the Xtensa advisory. |
## Post-pin release review, including changes without CVEs
| Product / version | Release-note date / GitHub publication UTC date | Scope checked |
| --- | --- | --- |
| wolfSSL 5.8.4 | 2025-11-20 / 2025-11-21 | Xtensa small implementations, Clang hardening, TLS advisories, ESP SHA changes. |
| wolfSSL 5.9.0 | 2026-03-18 / 2026-03-19 | TLS/PKCS/CRL advisories; RISC-V SP timing, KCAPI ECC import, RNG/port changes. |
| wolfSSL 5.9.1 | 2026-04-08 / 2026-04-08 | Certificate/digest checks; ECC import validation; SP and hardware-port hardening. |
| wolfSSL 5.9.2 | 2026-06-23 / 2026-06-25 | Latest stable returned; additional crypto validation, zeroization, SP/DH, PKCS, TLS and platform fixes. Unreleased master material is not a release candidate. |
| wolfSSH 1.4.21 | 2025-10-20 / 2025-10-22 | Client/SFTP CVEs; rekey PR 793, auth rejection fixes, keyboard callback changes. |
| wolfSSH 1.4.22 | 2026-01-05 / 2026-01-06 | State-machine/SCP CVEs; PRs 854 auth initialization, 857 string parsing, 856 worker/backpressure. |
| wolfSSH 1.5.0 | 2026-04-17 / 2026-04-20 | Latest stable returned; parser/bounds, callback defaults, key cleanup, KEX ordering, Windows CVE. |
Non-CVE findings are not automatically less important:
- **wolfSSH PR 892 (fixed in 1.5.0):** actual generated `DoIgnore` at line 5912 reads its length with unchecked `ato32`, ignores `len`, and advances by an unchecked peer length. `DoServiceRequest` at line 6124 also reads before checking; later service-name check does not establish four readable input bytes. Patch replaces these with bounded helpers. Password portion is already locally corrected, but the entire PR is not present.
- **wolfSSH PR 881 (1.5.0):** patch adds overflow check before `peerWindowSz += bytesToAdd`. Patch inspected; exact local function comparison was not completed. Potential availability/correctness issue, not a confirmed buffer overflow.
- **wolfSSH PR 899 (1.5.0):** patch bounds skips in `ParseRSAPubKey`/`ParseECCPubKey` and corrects `DoChannelFailure` length validation. Current role/reachability not finished; do not conflate client host-key parsing with server user-key authentication.
- **wolfSSH PR 880 (1.5.0):** release notes describe public-key type validation logical-operator correction. Actual generated `DoUserAuthRequestEcc` contains length mismatch **AND** `memcmp` mismatch checks for both key and signature type. This needs focused review with database key matching and attacker signature framing; exploit impact not established. Terminal-mode, agent, TPM, daemon changes in that PR need separate feature filtering.
- **wolfSSH PRs 918/919/902 (1.5.0):** forwarding/channel callback and payload hardening. FWD is absent; shell/exec/subsystem callbacks are registered, but default channel-open handling is not fully audited. No forwarding bypass claim.
- **wolfSSL PR 10133 (5.9.1):** ECC validation hardening. Actual `wc_ecc_import_x963_ex` calls `wc_ecc_check_key` only under absent `WOLFSSL_VALIDATE_ECC_IMPORT`. `HAVE_ECC_CHECK_PUBKEY_ORDER` is present inside `ecc.c`; its validation implementation can load curve B even without `USE_ECC_B_PARAM`. Do not infer “no point check anywhere” merely from absent B macro. Smallest candidate hardening is the upstream-recommended validation flag, pending full ECDH call trace and cost tests.
- **wolfSSL 5.9.2:** SP integer fixes, P521 SP ECDH 65/66-byte output check (PR 10702), DH subgroup hardening (PR 10560) are not current TFM/P256/no-DH features. Curve25519 all-zero-secret default checking (PR 10374), private-key clamp check (PR 10363), and broader ECC/EdDSA zeroization/validation fixes warrant follow-up. ESP port fixes in 5.9.1 PRs 10003/10080 need per-backend review; AES/hash hardware is disabled but that does not eliminate every ESP port path.
## Remaining wolfSSL CVE inventory collected
All identifiers in this section have the prefix **CVE-**. Ranges are CNA metadata, not proof of local reachability; `0` means the record supplied no meaningful historical lower bound. Every listed record was successfully queried at `https://cveawg.mitre.org/api/cve/<full-ID>`. Fixed versions and trigger descriptions came from the official security page and ChangeLog. This is a compact screening inventory, **not completed per-patch validation**.
| IDs | Published | Affected range / fixed | Current-feature screening |
| --- | --- | --- | --- |
| 2025-11931 | 2025-11-21 | `<5.8.4` / 5.8.4 | XChaCha API absent. |
| 2025-11932, 2025-12889 | 2025-11-21 | `<5.8.4` / 5.8.4 | wolfSSL TLS PSK/digest policy not used. |
| 2025-11933, 2025-11934, 2025-11935, 2025-11936 | 2025-11-21 | `[3.12.0,5.8.4)` / 5.8.4 | wolfSSL TLS1.3 not used. |
| 2026-0819 | 2026-03-19 | `[5.5.0,5.9.0)` / 5.9.0 | PKCS7 absent. |
| 2026-1005, 2026-2646 | 2026-03-19 | `<=5.8.4` / 5.9.0 | No wolfSSL sniffer/session restore use. |
| 2026-2645 | 2026-03-19 | `<5.8.4` / 5.8.4; further 5.9.0 hardening | wolfSSL TLS1.2 server not used; table headline 5.9.0 must not obscure earlier effective fix. |
| 2026-3229, 2026-3230, 2026-3547, 2026-3548, 2026-3549 | 2026-03-19 | `<5.9.0` / 5.9.0 | Compatibility certificate APIs/TLS/CRL not current SSH features. |
| 2026-3503 | 2026-03-19 | `[5.8.2,5.9.0)` / 5.9.0 | ARM Cortex-M PQ fault attack; wrong platform/features. |
| 2026-3579, 2026-3580 | 2026-03-19 | `<5.9.0` / 5.9.0 | RISC-V RV32I SP timing; wrong platform/math. |
| 2026-3849 | 2026-03-19 | 5.6.05.8.4 inclusive / 5.9.0 | HPKE/ECH TLS client not used. |
| 2026-4159, 2026-4395 | 2026-03-19 | `<5.9.0`; `<=5.8.4`, respectively / 5.9.0 | PKCS7 / Linux KCAPI ECC not current features. |
| 2026-5188 | 2026-04-10 | `<=5.9.0` / 5.9.1 | Original ASN SAN parser; current template parser, no SSH certificates. |
| 2026-5263, 2026-5264, 2026-5295, 2026-5392, 2026-5393, 2026-5447, 2026-5448, 2026-5460 | 2026-04-09 | `<5.9.1` / 5.9.1 | Certificate constraints, DTLS, PKCS7, experimental dual certs, compatibility APIs or TLS PQ paths not used. |
| 2026-5446 | 2026-04-09 | `[5.2.1,5.9.1)` / 5.9.1 | ARIA TLS not used. |
| 2026-5466, 2026-5479 | 2026-04-10 | `<5.9.1` / 5.9.1 | ECCSI / EVP ChaCha absent. |
| 2026-5477, 2026-5500, 2026-5501 | 2026-04-10 | `<=5.9.0` / 5.9.1 | EAX/CMAC >4GiB, PKCS7, compatibility X509 not used. |
| 2026-5503, 2026-5504, 2026-5507, 2026-5772, 2026-5778 | 2026-04-09 | `<=5.9.0` / 5.9.1 | ECH, PKCS7, session restore, hostname matcher, sniffer not used. |
| 2026-10097 | 2026-06-25 | `[5.7.0,5.9.1]` / 5.9.2 | AVX2 ML-KEM; wrong platform/feature. |
| 2026-10098 | 2026-06-25 | `[4.6.0,5.9.1]` / 5.9.2 | OCSP not used. |
| 2026-10512 | 2026-06-25 | `[5.6.4,5.9.1]` / 5.9.2 | X25519 x86_64 assembly; wrong implementation. |
| 2026-10592, 2026-6731, 2026-6412 | 2026-06-25 | `[3.9.10,5.9.1]` / 5.9.2 | Certificate name constraints/digest policy not current feature. |
| 2026-11310 | 2026-06-25 | `[5.8.4,5.9.1]` / 5.9.2 | Pin predates introduction; compatibility X509 absent. |
| 2026-11703 | 2026-06-25 | `[3.15.0,5.9.1]` / 5.9.2 | TLS SNI/ALPN resumption not used. |
| 2026-11999, 2026-6091, 2026-55964 | 2026-06-25 | `[5.7.4,5.9.1]` / 5.9.2 | Certificate path verification not current feature. |
| 2026-12340, 2026-55960 | 2026-06-25 | `[5.6.4,5.9.1]` / 5.9.2 | SM2 certificate/RPK TLS not used. |
| 2026-55958 | 2026-06-25 | `[5.4.0,5.9.1]` / 5.9.2 | Renesas TSIP TLS; wrong platform. |
| 2026-55961 | 2026-06-25 | `[3.15.7,5.9.1]` / 5.9.2 | Compatibility PKCS7 absent. |
| 2026-55962 | 2026-06-25 | `[5.5.4,5.9.1]` / 5.9.2 | TLS post-handshake authentication not used. |
| 2026-55967 | 2026-06-25 | `[4.8.0,5.9.1]` / 5.9.2 | GCM streaming >64GiB per message; streaming absent, not cumulative SSH session traffic. |
| 2026-6092 | 2026-06-25 | `[5.2.0,5.9.1]` / 5.9.2 | TLS Encrypt-then-MAC not used. |
| 2026-6094, 2026-7531 | 2026-06-25 | `[5.8.0,5.9.1]` / 5.9.2 | PKCS7 / TLS PQ hybrid not used. |
| 2026-6291 | 2026-06-25 | `[3.9.10,5.9.1]` / 5.9.2 | PKCS7 RSA padding oracle, not SSH raw key authentication. |
| 2026-6325 | 2026-06-25 | `[4.8.0,5.9.1]` / 5.9.2 | TLS signature-list setup not used. |
| 2026-6329 | 2026-06-25 | `[3.10.0,5.9.1]` / 5.9.2 | PKCS12 MAC verification not a current SSH workflow. |
| 2026-6330 | 2026-06-25 | `[5.7.4,5.9.1]` / 5.9.2 | ARM64 ML-KEM; wrong platform/feature. |
| 2026-6331, 2026-6678, 2026-7511 | 2026-06-25 | `[3.15.5,5.9.1]` / 5.9.2 | EVP HMAC / PKCS7 APIs not used. |
| 2026-6450 | 2026-06-25 | `[4.3.0,5.9.1]` / 5.9.2 | CRL not used. |
| 2026-6679 | 2026-06-25 | `[5.4.0,5.9.0]` / 5.9.1 | DTLS not used. Listed under 5.9.2 but narrative says already fixed 5.9.1. |
| 2026-6681 | 2026-06-25 | `[3.10.0,5.9.0]` / 5.9.1 | PKCS7 absent; same release-table/narrative discrepancy. |
| 2026-7532 | 2026-06-25 | `<=5.9.1` / 5.9.2 | X509 IP constraints not used. Related advisory ID TALOS-2026-2409 appears in upstream ChangeLog; Talos page not queried. |
| 2026-8720 | 2026-06-25 | `[5.9.0,5.9.1]` / 5.9.2 | HMAC-BLAKE2 APIs introduced after pin. |
## Queried sources and caveats
All following requests occurred during this **2026-09-15** review. Network requests succeeded; no blocked-network limitation applies to collected external evidence. The last attempted local read/status command failed with `authorization channel closed`; no final repository status/diff validation was obtained.
Official index and release sources fetched:
- https://www.wolfssl.com/docs/security-vulnerabilities/
- https://github.com/wolfSSL/wolfssh/releases
- https://raw.githubusercontent.com/wolfSSL/wolfssh/master/ChangeLog.md
- https://raw.githubusercontent.com/wolfSSL/wolfssl/master/ChangeLog.md
- https://api.github.com/repos/wolfSSL/wolfssl/releases?per_page=6
- https://api.github.com/repos/wolfSSL/wolfssh/releases?per_page=6
Patch URLs actually queried (read only; no patch applied):
- https://github.com/wolfSSL/wolfssl/pull/9275.diff
- https://github.com/wolfSSL/wolfssl/pull/10131.diff
- https://github.com/wolfSSL/wolfssl/pull/10133.diff
- https://github.com/wolfSSL/wolfssl/pull/10025.diff
- https://github.com/wolfSSL/wolfssh/pull/855.diff
- https://github.com/wolfSSL/wolfssh/pull/892.diff
- https://github.com/wolfSSL/wolfssh/pull/881.diff
- https://github.com/wolfSSL/wolfssh/pull/899.diff
CVE metadata: exact URL construction is `https://cveawg.mitre.org/api/cve/` followed by each full CVE identifier in the priority table and inventory above. Every such ID was queried; some twice. These are vendor CNA records served by the CVE API. Full paths are mechanically recoverable without search, e.g. https://cveawg.mitre.org/api/cve/CVE-2025-12888 and https://cveawg.mitre.org/api/cve/CVE-2025-14942 . No NVD or GitHub advisory API query was made.
Additional upstream patch links **identified in release notes, not fetched**: wolfSSH [840](https://github.com/wolfSSL/wolfssh/pull/840), [834](https://github.com/wolfSSL/wolfssh/pull/834), [859](https://github.com/wolfSSL/wolfssh/pull/859), [864](https://github.com/wolfSSL/wolfssh/pull/864), [793](https://github.com/wolfSSL/wolfssh/pull/793), [854](https://github.com/wolfSSL/wolfssh/pull/854), [856](https://github.com/wolfSSL/wolfssh/pull/856), [857](https://github.com/wolfSSL/wolfssh/pull/857), [880](https://github.com/wolfSSL/wolfssh/pull/880), [902](https://github.com/wolfSSL/wolfssh/pull/902), [918](https://github.com/wolfSSL/wolfssh/pull/918), [919](https://github.com/wolfSSL/wolfssh/pull/919), [921](https://github.com/wolfSSL/wolfssh/pull/921); wolfSSL [9148](https://github.com/wolfSSL/wolfssl/pull/9148), [10003](https://github.com/wolfSSL/wolfssl/pull/10003), [10080](https://github.com/wolfSSL/wolfssl/pull/10080), [10363](https://github.com/wolfSSL/wolfssl/pull/10363), [10374](https://github.com/wolfSSL/wolfssl/pull/10374), [10560](https://github.com/wolfSSL/wolfssl/pull/10560), [10702](https://github.com/wolfSSL/wolfssl/pull/10702).
Evidence limitations:
- The consolidated official page omitted the four wolfSSH CVEs published with 1.4.21/1.4.22 despite listing older and newer entries. **Neither that omission nor the user's earlier empty GitHub advisory tabs proves absence of vulnerabilities.** GitHub advisory tabs were not re-queried here.
- Release-note dates differ from GitHub publication dates. CNA metadata has malformed ranges and conflicting references as noted. No date was inferred from a CVE year.
- Master ChangeLogs and PR diffs are mutable. Release tags/timestamps were queried, but upstream full commit hashes and archived evidence snapshots were not captured. Some long patch responses were output-limited; do not interpret a successful fetch as a complete hunk-by-hunk audit.
- Macro evidence is stronger than default Kconfig assumptions but not disassembly/timing evidence. Whole-library API reachability/linker elimination, every math/port fix, low-order X25519 behavior, RNG error paths, and ECC validation call chains remain unfinished.
- Older entries such as wolfSSH CVE-2024-2873 (fixed 1.4.17) were visible in the index; this report is focused on post-pin maintenance and does not certify every historical advisory.
- Parent follow-up: resolve the current-path priorities, choose a coherent upgrade/backport strategy, add negative message-order/parser/key-validation tests against actual generated sources, and run firmware plus whole-Phase-9 hardware gates. No hardware evidence is implied here.
+5 -1
View File
@@ -2,7 +2,11 @@
default_envs = esp32-s3-devkitc-1-n16r8
[env:esp32-s3-devkitc-1-n16r8]
platform = platformio/espressif32@6.12.0
platform = platformio/espressif32@6.13.0
platform_packages =
platformio/framework-espidf@3.50503.0
platformio/toolchain-xtensa-esp-elf@14.2.0+20251107
platformio/toolchain-riscv32-esp@14.2.0+20251107
board = esp32-s3-devkitc-1-n16r8
framework = espidf
+23 -1
View File
@@ -1,4 +1,6 @@
# ESP32-S3-WROOM-1-N16R8 hardware configuration
# Single-variable web throughput experiment; target validation pending.
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
@@ -14,7 +16,8 @@ CONFIG_ESP_WIFI_RX_BA_WIN=6
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4
# Keep concurrent HTTPS handshakes from exhausting scarce internal DRAM.
# Active TLS material remains unencrypted in PSRAM until the hardening phase.
# Active TLS material remains unencrypted in PSRAM; physical extraction is outside
# the supported threat model (docs/security_hardening.md).
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
# Hardware AES can hang in the PSRAM DMA path and cannot be shared safely with
@@ -70,3 +73,22 @@ CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT=y
# Keep diagnostic and interactive-console logging concise but useful.
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# Crash memory/registers can contain credentials and serial payloads. Keep the
# reserved coredump partition unused; enforce resolved settings at compile time.
CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y
# CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH is not set
# CONFIG_ESP_COREDUMP_ENABLE_TO_UART is not set
CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y
# CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT is not set
# CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT is not set
# CONFIG_ESP_SYSTEM_PANIC_GDBSTUB is not set
# CONFIG_ESP_SYSTEM_GDBSTUB_RUNTIME is not set
# CONFIG_ESP_DEBUG_OCDAWARE is not set
# CONFIG_FREERTOS_DEBUG_OCDAWARE is not set
# Audited allocation extents and TLS record retirement used by Phase 9C.
CONFIG_HEAP_POISONING_DISABLED=y
# CONFIG_HEAP_POISONING_LIGHT is not set
# CONFIG_HEAP_POISONING_COMPREHENSIVE is not set
# CONFIG_MBEDTLS_DYNAMIC_BUFFER is not set
+9
View File
@@ -6,6 +6,7 @@ idf_component_register(
"network_console.c"
"system_console.c"
"secure_random.c"
"security_build_policy.c"
"status_led.c"
"local_display.c"
"local_boot_animation.c"
@@ -24,6 +25,9 @@ idf_component_register(
"admin_command_gate.c"
"admin_ssh_console.c"
"ssh_transport.c"
"ssh_auth_policy.c"
"ssh_memory.c"
"ssh_protocol_policy.c"
"ssh_console.c"
"usb_cdc_transport.c"
"usb_console.c"
@@ -33,6 +37,11 @@ idf_component_register(
"web_serial_transport.c"
"web_serial_settings.c"
"web_account_settings.c"
"web_network_settings.c"
"web_display_settings.c"
"web_broker_settings.c"
"web_ssh_settings.c"
"web_lifecycle_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+87 -2
View File
@@ -18,6 +18,11 @@
#include "user_database.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_broker_settings.h"
#include "web_ssh_settings.h"
#include "web_lifecycle_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -64,6 +69,7 @@ typedef struct {
bool discard_next_lf;
admin_prompt_state_t prompt_state;
bool prompt_hidden;
bool prompt_rejected;
size_t prompt_capacity;
size_t prompt_length;
uint8_t prompt_input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
@@ -85,6 +91,11 @@ typedef enum {
ADMIN_REQUEST_DEFERRED,
ADMIN_REQUEST_SERIAL_SETTINGS,
ADMIN_REQUEST_ACCOUNT_SETTINGS,
ADMIN_REQUEST_NETWORK_SETTINGS,
ADMIN_REQUEST_DISPLAY_SETTINGS,
ADMIN_REQUEST_BROKER_SETTINGS,
ADMIN_REQUEST_SSH_SETTINGS,
ADMIN_REQUEST_LIFECYCLE_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -97,6 +108,11 @@ typedef struct {
admin_control_request_t deferred;
uint32_t serial_settings_id;
uint32_t account_settings_id;
uint32_t network_settings_id;
uint32_t display_settings_id;
uint32_t broker_settings_id;
uint32_t ssh_settings_id;
uint32_t lifecycle_settings_id;
};
} admin_request_t;
@@ -370,6 +386,7 @@ esp_err_t admin_ssh_console_dispatch_read_input(
session->prompt_length = 0U;
session->prompt_capacity = capacity;
session->prompt_hidden = hidden;
session->prompt_rejected = false;
session->prompt_state = ADMIN_PROMPT_WAITING;
bool published = append_output_locked(session, (const uint8_t *)prompt, strlen(prompt));
if (!published) {
@@ -398,14 +415,19 @@ esp_err_t admin_ssh_console_dispatch_read_input(
if (!current || !session->active || session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
result = ESP_ERR_NOT_FOUND;
} else if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
if (session->prompt_rejected) {
result = ESP_ERR_INVALID_SIZE;
} else {
memcpy(output, session->prompt_input, session->prompt_length);
*output_length = session->prompt_length;
result = ESP_OK;
}
}
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
session->prompt_length = 0U;
session->prompt_capacity = 0U;
session->prompt_hidden = false;
session->prompt_rejected = false;
session->prompt_state = ADMIN_PROMPT_NONE;
taskEXIT_CRITICAL(&s_lock);
return result;
@@ -672,6 +694,56 @@ esp_err_t admin_ssh_console_submit_account_settings(uint32_t id)
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_NETWORK_SETTINGS, .network_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_DISPLAY_SETTINGS, .display_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_BROKER_SETTINGS, .broker_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_SSH_SETTINGS, .ssh_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_LIFECYCLE_SETTINGS, .lifecycle_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -680,9 +752,17 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) {
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS ||
request.origin == ADMIN_REQUEST_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS ||
request.origin == ADMIN_REQUEST_LIFECYCLE_SETTINGS) {
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
else web_account_settings_execute(request.account_settings_id);
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id);
else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id);
else if (request.origin == ADMIN_REQUEST_BROKER_SETTINGS) web_broker_settings_execute(request.broker_settings_id);
else if (request.origin == ADMIN_REQUEST_SSH_SETTINGS) web_ssh_settings_execute(request.ssh_settings_id);
else web_lifecycle_settings_execute(request.lifecycle_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
@@ -1136,9 +1216,14 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
if (!session->prompt_hidden) {
(void)append_output_locked(session, &value, 1U);
}
} else if (session->prompt_hidden) {
session->prompt_rejected = true;
} else {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
}
} else if (session->prompt_hidden) {
/* Do not silently normalize unrepresentable credential bytes. */
session->prompt_rejected = true;
}
}
++*consumed;
+5 -6
View File
@@ -17,6 +17,11 @@ extern "C" {
/* Nonblocking typed settings admission to the canonical dispatcher. */
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
@@ -137,12 +142,6 @@ esp_err_t admin_ssh_console_dispatch_read_input(
esp_err_t admin_ssh_console_dispatch_defer(
admin_ssh_deferred_action_type_t action, uint32_t argument);
/* SSH compatibility entry point, implemented by the owner in ssh_transport.c.
* Token/principal are copied; no SSH or socket objects cross this boundary.
* Existing feed/close/read/snapshot APIs below also accept open_owned tokens.
*/
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
/* Called by the session owner. Returns false when input must be backpressured. */
+4
View File
@@ -186,6 +186,10 @@ static const char *const s_completion_candidates[] = {
"web diagnostics disable",
"web diagnostics show",
"web diagnostics clear",
"web performance enable",
"web performance disable",
"web performance show",
"web performance clear",
"web certificate",
"web certificate info",
"web certificate rotate",
+11
View File
@@ -43,6 +43,7 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
return error;
}
bool rejected = false;
for (;;) {
uint8_t byte = 0U;
if (uart_read_bytes(CONSOLE_INPUT_UART, &byte, 1U, portMAX_DELAY) != 1) {
@@ -71,6 +72,11 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
continue;
}
if (byte < 0x20U || byte > 0x7eU || *output_length >= capacity - 1U) {
/* Hidden credentials must never accept a truncated/normalized prefix. */
if (hidden) {
rejected = true;
continue;
}
putchar('\a');
fflush(stdout);
continue;
@@ -82,6 +88,11 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
}
}
putchar('\n');
if (rejected) {
secure_wipe(output, capacity);
*output_length = 0U;
return ESP_ERR_INVALID_SIZE;
}
return ESP_OK;
}
+4
View File
@@ -8,6 +8,10 @@
#include "esp_err.h"
/* Hidden input accepts printable ASCII with CR/LF submit, BS/DEL editing and
* Ctrl-C cancellation. Overflow or any other byte rejects the entire prompt on
* submit (ESP_ERR_INVALID_SIZE), even after editing; rejected input is wiped.
* capacity includes the trailing NUL. Visible line editing is unchanged. */
esp_err_t console_input_read_hidden(const char *prompt,
uint8_t *output, size_t capacity,
size_t minimum_length, size_t maximum_length,
+61 -13
View File
@@ -203,6 +203,8 @@ static TickType_t s_diagnostic_hold_started;
static uint32_t s_external_activity_sequence;
static local_ui_config_t s_config;
static bool s_config_available;
static bool s_config_busy;
static uint32_t s_config_generation;
static const gpio_num_t s_button_gpios[LOCAL_STATUS_BUTTON_COUNT] = {
LOCAL_UI_BUTTON_PREVIOUS_GPIO,
@@ -1368,21 +1370,62 @@ esp_err_t local_status_ui_get_config(local_ui_config_t *config)
return available ? ESP_OK : ESP_ERR_INVALID_STATE;
}
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation)
{
if (config == NULL || generation == NULL) return ESP_ERR_INVALID_ARG;
portENTER_CRITICAL(&s_timing_mux);
esp_err_t error = !s_config_available ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
*config = s_config;
*generation = s_config_generation;
}
portEXIT_CRITICAL(&s_timing_mux);
return error;
}
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults)
{
if (loaded_defaults != NULL) *loaded_defaults = false;
if (action < LOCAL_UI_SETTINGS_APPLY || action > LOCAL_UI_SETTINGS_RESET ||
(action == LOCAL_UI_SETTINGS_APPLY && local_ui_config_validate(config) != ESP_OK))
return ESP_ERR_INVALID_ARG;
local_ui_config_t candidate;
portENTER_CRITICAL(&s_timing_mux);
esp_err_t error = !s_config_available ||
(expected_generation && expected_generation != s_config_generation) ||
s_config_generation == UINT32_MAX ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
candidate = action == LOCAL_UI_SETTINGS_APPLY ? *config : s_config;
s_config_busy = true;
}
portEXIT_CRITICAL(&s_timing_mux);
if (error != ESP_OK) return error;
bool stored = true;
if (action == LOCAL_UI_SETTINGS_LOAD) error = local_ui_config_load(&candidate, &stored);
if (action == LOCAL_UI_SETTINGS_DEFAULTS || action == LOCAL_UI_SETTINGS_RESET)
local_ui_config_defaults(&candidate);
if (action == LOCAL_UI_SETTINGS_SAVE || action == LOCAL_UI_SETTINGS_RESET)
error = local_ui_config_save(&candidate);
portENTER_CRITICAL(&s_timing_mux);
if (error == ESP_OK && action != LOCAL_UI_SETTINGS_SAVE) {
s_config = candidate;
++s_config_generation;
++s_external_activity_sequence;
}
s_config_busy = false;
portEXIT_CRITICAL(&s_timing_mux);
if (error == ESP_OK && loaded_defaults != NULL) *loaded_defaults = !stored;
return error;
}
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config)
{
esp_err_t error = local_ui_config_validate(config);
if (error != ESP_OK) {
return error;
}
portENTER_CRITICAL(&s_timing_mux);
if (!s_config_available) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
}
s_config = *config;
++s_external_activity_sequence;
portEXIT_CRITICAL(&s_timing_mux);
return ESP_OK;
return local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, 0, config, NULL);
}
esp_err_t local_status_ui_start(const local_ui_config_t *config)
@@ -1397,6 +1440,11 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config)
portENTER_CRITICAL(&s_timing_mux);
s_config = *config;
if (s_config_generation == UINT32_MAX) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
}
++s_config_generation;
s_config_available = true;
portEXIT_CRITICAL(&s_timing_mux);
+16
View File
@@ -21,6 +21,22 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config);
esp_err_t local_status_ui_get_config(local_ui_config_t *config);
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config);
typedef enum {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
} local_ui_settings_action_t;
/* Zero-wait RAM projection. Generation is nonzero and never wraps. */
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation);
/* Reserve configuration across storage IO, without holding a critical section.
* Zero expected_generation is for canonical unconditional CLI operations only.
* Nonzero stale generations return ESP_ERR_INVALID_STATE; contention returns
* ESP_ERR_TIMEOUT. Load retains the canonical default fallback. Reset commits
* defaults before publishing RAM, so a storage failure needs no RAM rollback.
* No display IO occurs here; successful RAM changes signal renderer activity. */
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults);
/* Preserve a manually selected display diagnostic for a bounded interval. */
void local_status_ui_hold_for_diagnostics(void);
+9 -24
View File
@@ -87,7 +87,8 @@ static int apply_parameter(const char *parameter, const char *text)
}
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
uint32_t generation;
esp_err_t error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
printf("Could not read local UI configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -102,7 +103,7 @@ static int apply_parameter(const char *parameter, const char *text)
return 1;
}
error = local_status_ui_apply_config(&config);
error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, generation, &config, NULL);
if (error != ESP_OK) {
printf("Invalid display configuration: %s. When both timeouts are enabled, off must be later than dim.\n",
esp_err_to_name(error));
@@ -122,11 +123,7 @@ static int command_display(int argc, char **argv)
return apply_parameter(argv[2], argv[3]);
}
if (argc == 2 && strcmp(argv[1], "save") == 0) {
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
if (error == ESP_OK) {
error = local_ui_config_save(&config);
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_SAVE, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not save display configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -136,17 +133,15 @@ static int command_display(int argc, char **argv)
}
if (argc == 2 && strcmp(argv[1], "load") == 0) {
local_ui_config_t config;
bool used_stored_config;
esp_err_t error = local_ui_config_load(&config, &used_stored_config);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&config);
}
bool loaded_defaults = false;
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_LOAD, 0, NULL, &loaded_defaults);
if (error == ESP_OK) error = local_status_ui_get_config(&config);
if (error != ESP_OK) {
printf("Could not load display configuration: %s\n", esp_err_to_name(error));
return 1;
}
printf("Loaded %s display configuration.\n",
used_stored_config ? "stored" : "default");
loaded_defaults ? "default" : "stored");
print_config(&config);
return 0;
}
@@ -163,19 +158,9 @@ static int command_display(int argc, char **argv)
return 0;
}
if (argc == 2 && strcmp(argv[1], "reset") == 0) {
local_ui_config_t previous;
local_ui_config_t defaults;
local_ui_config_defaults(&defaults);
esp_err_t error = local_status_ui_get_config(&previous);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&defaults);
}
if (error == ESP_OK) {
error = local_ui_config_reset_storage();
if (error != ESP_OK) {
(void)local_status_ui_apply_config(&previous);
}
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not reset display configuration: %s\n", esp_err_to_name(error));
return 1;
+51 -6
View File
@@ -12,6 +12,7 @@
static SemaphoreHandle_t s_mutex;
static mdns_config_t s_config;
static uint32_t s_config_generation;
static bool s_component_initialized;
static bool s_initialization_failed;
static bool s_announced;
@@ -45,6 +46,7 @@ esp_err_t mdns_service_init(const mdns_config_t *config)
return ESP_ERR_NO_MEM;
}
s_config = *config;
s_config_generation = 1;
s_last_error = ESP_OK;
return ESP_OK;
}
@@ -66,27 +68,68 @@ esp_err_t mdns_service_set_config(const mdns_config_t *config)
return ESP_ERR_INVALID_ARG;
}
lock_service();
if (s_config_generation == UINT32_MAX) { unlock_service(); return ESP_ERR_INVALID_STATE; }
s_config = *config;
++s_config_generation;
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
static void snapshot_locked(mdns_service_snapshot_t *snapshot)
{
if (snapshot == NULL || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_service();
memset(snapshot, 0, sizeof(*snapshot));
snapshot->config_generation = s_config_generation;
snapshot->initialized = true;
snapshot->announced = s_announced;
memcpy(snapshot->suffix, s_config.suffix, s_config.suffix_len);
make_hostname(&s_config, snapshot->hostname, sizeof(snapshot->hostname));
snapshot->last_error = s_last_error;
}
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
{
if (!snapshot || !s_mutex) return ESP_ERR_INVALID_STATE;
lock_service();
snapshot_locked(snapshot);
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot)
{
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
snapshot_locked(snapshot);
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
const mdns_config_t *config, bool *stored)
{
if (!stored || action > MDNS_SETTINGS_DEFAULTS || action < MDNS_SETTINGS_SET ||
(action == MDNS_SETTINGS_SET && mdns_config_validate(config) != ESP_OK)) return ESP_ERR_INVALID_ARG;
*stored = true;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_service();
if (!generation || generation != s_config_generation) { unlock_service(); return ESP_ERR_NOT_FOUND; }
esp_err_t error = ESP_OK;
mdns_config_t candidate = s_config;
if (action == MDNS_SETTINGS_SAVE) error = mdns_config_save(&s_config);
else if (s_config_generation == UINT32_MAX) error = ESP_ERR_INVALID_STATE;
else {
if (action == MDNS_SETTINGS_SET) candidate = *config;
else if (action == MDNS_SETTINGS_LOAD) error = mdns_config_load(&candidate, stored);
else mdns_config_defaults(&candidate);
if (error == ESP_OK) error = mdns_config_validate(&candidate);
if (error == ESP_OK) { s_config = candidate; ++s_config_generation; }
}
unlock_service();
return error;
}
esp_err_t mdns_service_start(void)
{
if (s_mutex == NULL) {
@@ -96,7 +139,9 @@ esp_err_t mdns_service_start(void)
if (s_component_initialized) {
s_announced = true;
unlock_service();
return ESP_OK;
/* A suffix staged while offline must reach the already-created responder
* when the next STA IP arrives, even if its reannounce command ran offline. */
return mdns_service_reannounce();
}
if (s_initialization_failed) {
esp_err_t error = s_last_error;
+11
View File
@@ -9,6 +9,7 @@
#include "mdns_config.h"
typedef struct {
uint32_t config_generation;
bool initialized;
bool announced;
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
@@ -21,6 +22,16 @@ esp_err_t mdns_service_get_config(mdns_config_t *config);
esp_err_t mdns_service_set_config(const mdns_config_t *config);
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot);
/* Zero-wait secret-free projection for HTTPD; ESP_ERR_TIMEOUT on contention. */
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot);
typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD,
MDNS_SETTINGS_DEFAULTS } mdns_settings_action_t;
/* Dispatcher-only. Check generation and mutate/persist under the service mutex.
* ESP_ERR_NOT_FOUND is stale. LOAD may select deterministic MAC defaults (stored
* reports that distinction). Caller separately queues manager reannouncement. */
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
const mdns_config_t *config, bool *stored);
/* Only wifi_manager may call these lifecycle operations. */
esp_err_t mdns_service_start(void);
void mdns_service_stop(void);
+13 -1
View File
@@ -12,6 +12,7 @@
#include "esp_console.h"
#include "esp_err.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
@@ -186,7 +187,9 @@ typedef struct {
#define PING_EVENT_QUEUE_LENGTH (PING_MAX_COUNT + 1U)
static StaticQueue_t s_ping_queue_storage;
static uint8_t s_ping_queue_bytes[PING_EVENT_QUEUE_LENGTH * sizeof(ping_event_t)];
/* Dispatcher-owned lazy payload; retain for firmware lifetime so callback queue
* storage cannot dangle. Queue control stays internal. No internal-RAM fallback. */
static uint8_t *s_ping_queue_bytes;
static QueueHandle_t s_ping_queue;
static void ping_on_success(esp_ping_handle_t handle, void *arguments)
@@ -289,6 +292,15 @@ static int execute_ping(int argc, char **argv)
return 1;
}
if (s_ping_queue_bytes == NULL) {
s_ping_queue_bytes = heap_caps_malloc(
PING_EVENT_QUEUE_LENGTH * sizeof(ping_event_t),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (s_ping_queue_bytes == NULL) {
printf("ping: PSRAM event storage unavailable\n");
return 1;
}
}
if (s_ping_queue == NULL) {
s_ping_queue = xQueueCreateStatic(PING_EVENT_QUEUE_LENGTH, sizeof(ping_event_t),
s_ping_queue_bytes, &s_ping_queue_storage);
+30
View File
@@ -0,0 +1,30 @@
#include "sdkconfig.h"
/* Check resolved configuration, not just defaults: existing sdkconfig files
* survive default changes. Crash memory may contain credentials and UART data.
* See docs/security_hardening.md before changing this supported-build policy. */
#if !defined(CONFIG_ESP_COREDUMP_ENABLE_TO_NONE) || !CONFIG_ESP_COREDUMP_ENABLE_TO_NONE
#error "Security policy: select CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y"
#endif
#if CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH || CONFIG_ESP_COREDUMP_ENABLE_TO_UART || CONFIG_ESP_COREDUMP_ENABLE
#error "Security policy: flash and UART core dumps must be disabled"
#endif
#if !defined(CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT) || !CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT
#error "Security policy: select CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y"
#endif
#if CONFIG_ESP_SYSTEM_PANIC_PRINT_HALT || CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT || CONFIG_ESP_SYSTEM_PANIC_GDBSTUB
#error "Security policy: panic register output and panic GDB stub must be disabled"
#endif
#if CONFIG_ESP_SYSTEM_GDBSTUB_RUNTIME
#error "Security policy: runtime GDB stub must be disabled"
#endif
/* Prevent a connected debugger from replacing panic recovery with a halt.
* This is not a physical JTAG access restriction and does not change eFuses. */
#if CONFIG_ESP_DEBUG_OCDAWARE || CONFIG_FREERTOS_DEBUG_OCDAWARE
#error "Security policy: JTAG/OCD-aware panic handling must be disabled"
#endif
+58 -5
View File
@@ -47,6 +47,8 @@ static session_broker_slot_t s_slots[SESSION_BROKER_MAX_CLIENTS];
static session_broker_client_id_t s_writer_id;
static uint32_t s_connected_clients;
static uint64_t s_event_sequence;
/* Saturation disables management confirmations, never ordinary recovery. */
static uint32_t s_writer_generation = 1U;
static session_broker_global_counters_t s_counters;
static bool s_initialized;
@@ -102,6 +104,10 @@ static void broadcast_event_locked(session_broker_event_type_t type,
session_broker_client_id_t client_id,
session_broker_client_id_t writer_id)
{
if ((type == SESSION_BROKER_EVENT_WRITER_GRANTED ||
type == SESSION_BROKER_EVENT_WRITER_RELEASED ||
type == SESSION_BROKER_EVENT_WRITER_REVOKED) && s_writer_generation != UINT32_MAX)
++s_writer_generation;
session_broker_event_t event = {
.sequence = ++s_event_sequence,
.type = type,
@@ -149,6 +155,10 @@ static void fan_out_rx_locked(const uint8_t *data, size_t size)
slot->counters.uart_rx_bytes += size;
size_t queued = xStreamBufferSend(slot->output, data, size, 0);
size_t pending = xStreamBufferBytesAvailable(slot->output);
if (pending > slot->counters.output_high_water_bytes) {
slot->counters.output_high_water_bytes = pending;
}
size_t dropped = size - queued;
slot->counters.output_queued_bytes += queued;
slot->counters.output_dropped_bytes += dropped;
@@ -295,7 +305,8 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
session_broker_slot_t *slot = NULL;
size_t slot_index = 0U;
for (; slot_index < SESSION_BROKER_MAX_CLIENTS; ++slot_index) {
if (!s_slots[slot_index].connected) {
if (!s_slots[slot_index].connected &&
s_slots[slot_index].generation < SESSION_BROKER_MAX_GENERATION) {
slot = &s_slots[slot_index];
break;
}
@@ -315,9 +326,7 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
}
uint32_t generation = slot->generation + 1U;
if (generation == 0U || generation > SESSION_BROKER_MAX_GENERATION) {
generation = 1U;
}
/* Exhausted slots are retired until reboot: no 29-bit ID reuse. */
xStreamBufferReset(slot->output);
xQueueReset(slot->events);
@@ -466,13 +475,19 @@ esp_err_t session_broker_release_writer(session_broker_client_id_t client_id)
return ESP_OK;
}
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
static esp_err_t broker_force_writer(session_broker_client_id_t client_id,
uint32_t expected_generation)
{
if (!s_initialized) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (expected_generation && (expected_generation == UINT32_MAX ||
expected_generation != s_writer_generation)) {
xSemaphoreGive(s_mutex);
return ESP_ERR_INVALID_STATE;
}
session_broker_slot_t *new_writer = NULL;
if (client_id != SESSION_BROKER_NO_CLIENT) {
new_writer = find_slot_locked(client_id);
@@ -518,6 +533,41 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
return ESP_OK;
}
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
{
return broker_force_writer(client_id, 0);
}
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
uint32_t generation)
{
if (!client_id || !generation) return ESP_ERR_INVALID_ARG;
return broker_force_writer(client_id, generation);
}
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot)
{
if (!snapshot) return ESP_ERR_INVALID_ARG;
if (!s_initialized) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
memset(snapshot, 0, sizeof(*snapshot));
snapshot->generation = s_writer_generation;
snapshot->writer_id = s_writer_id;
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
const session_broker_slot_t *slot = &s_slots[i];
if (!slot->connected) continue;
session_broker_management_client_t *client = &snapshot->clients[snapshot->count++];
client->id = slot->id;
client->type = slot->type;
memcpy(client->name, slot->name, sizeof(client->name));
client->pending = xStreamBufferBytesAvailable(slot->output);
client->high_water = slot->counters.output_high_water_bytes;
client->dropped = slot->counters.output_dropped_bytes;
}
xSemaphoreGive(s_mutex);
return ESP_OK;
}
esp_err_t session_broker_force_release_writer(
session_broker_client_id_t expected_writer_id)
{
@@ -740,6 +790,8 @@ esp_err_t session_broker_clear_counters(void)
memset(&s_counters, 0, sizeof(s_counters));
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
memset(&s_slots[i].counters, 0, sizeof(s_slots[i].counters));
s_slots[i].counters.output_high_water_bytes =
xStreamBufferBytesAvailable(s_slots[i].output);
}
xSemaphoreGive(s_mutex);
return ESP_OK;
@@ -758,6 +810,7 @@ esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client
return ESP_ERR_NOT_FOUND;
}
memset(&slot->counters, 0, sizeof(slot->counters));
slot->counters.output_high_water_bytes = xStreamBufferBytesAvailable(slot->output);
xSemaphoreGive(s_mutex);
return ESP_OK;
}
+39 -2
View File
@@ -49,6 +49,11 @@ typedef struct {
session_broker_client_id_t writer_id;
} session_broker_event_t;
/*
* Active-client counters cover this connection (or the last counter clear).
* They become unavailable on disconnect and reset on slot/generation reuse;
* global totals retain disconnected clients' traffic until explicitly cleared.
*/
typedef struct {
/* UART bytes considered for delivery while this client was connected. */
uint64_t uart_rx_bytes;
@@ -67,13 +72,18 @@ typedef struct {
uint64_t events_queued;
uint64_t events_popped;
uint64_t event_drops;
/* Peak output occupancy, <= SESSION_BROKER_OUTPUT_SIZE; clear seeds pending. */
size_t output_high_water_bytes;
} session_broker_client_counters_t;
typedef struct {
uint64_t uart_rx_bytes;
/* UART RX drained when there were no connected observers. */
uint64_t unobserved_rx_bytes;
/* Queue/read/drop totals count one copy per client observer. */
/* Queue/read/drop totals count one copy per client observer.
* Drops include full-buffer losses and unread output discarded on disconnect.
* Read means handed to a transport, not confirmed delivery to its peer.
*/
uint64_t output_queued_bytes;
uint64_t output_read_bytes;
uint64_t output_dropped_bytes;
@@ -110,6 +120,31 @@ typedef struct {
session_broker_global_counters_t counters;
} session_broker_global_snapshot_t;
/* Compact, atomic, non-consuming management projection. No transport pointers. */
typedef struct {
session_broker_client_id_t id;
session_broker_client_type_t type;
char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
size_t pending, high_water;
uint64_t dropped;
} session_broker_management_client_t;
typedef struct {
uint32_t generation;
session_broker_client_id_t writer_id;
size_t count;
session_broker_management_client_t clients[SESSION_BROKER_MAX_CLIENTS];
} session_broker_management_snapshot_t;
/* Zero-wait atomic snapshot. Generation survives counter clears; UINT32_MAX
* means confirmations exhausted until reboot. Every lease transition advances
* it, including release/reacquire ABA. Client IDs never wrap within a boot. */
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot);
/* Nonzero target and generation required; compare + target validation + transfer
* share the broker lock. Stale/exhausted generation or absent target has no effects.
* Existing unconditional force remains available to recovery/console callers. */
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
uint32_t generation);
/*
* Allocates all eight output streams and event queues, then starts the
* permanent broker task. The serial service must already be initialized
@@ -158,7 +193,9 @@ esp_err_t session_broker_get_global_snapshot(session_broker_global_snapshot_t *s
size_t session_broker_list_clients(session_broker_client_snapshot_t *clients,
size_t capacity);
/* Counter clearing does not reset client IDs, queued data, or event sequence. */
/* Counter clearing does not reset client IDs, queued data, or event sequence.
* Client output high-water marks restart at current queued occupancy, not zero.
*/
esp_err_t session_broker_clear_counters(void);
esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client_id);
+20
View File
@@ -182,6 +182,26 @@ static int show_counters(void)
counter->events_queued,
counter->events_popped,
counter->event_drops);
session_broker_client_snapshot_t clients[SESSION_BROKER_MAX_CLIENTS];
size_t count = session_broker_list_clients(clients, SESSION_BROKER_MAX_CLIENTS);
printf("Active clients (since connect/clear; lost on disconnect; global totals retained):\n");
printf("Output bytes: HWM <= %u; clear seeds pending; read = handed to transport.\n",
(unsigned int)SESSION_BROKER_OUTPUT_SIZE);
printf("Global dropped also includes unread output discarded on disconnect.\n");
printf("ID type pending HWM UART queued read dropped\n");
for (size_t index = 0; index < count; ++index) {
const session_broker_client_snapshot_t *client = &clients[index];
printf("%-10lu %-9s %-7u %-7u %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 "\n",
(unsigned long)client->id,
client_type_name(client->type),
(unsigned int)client->output_bytes_pending,
(unsigned int)client->counters.output_high_water_bytes,
client->counters.uart_rx_bytes,
client->counters.output_queued_bytes,
client->counters.output_read_bytes,
client->counters.output_dropped_bytes);
}
return 0;
}
+50
View File
@@ -0,0 +1,50 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_auth_policy.h"
#include <stddef.h>
bool ssh_auth_policy_admit(ssh_auth_policy_t *policy,
ssh_auth_policy_kind_t kind, int64_t now_us)
{
if (policy == NULL || (unsigned)kind >= SSH_AUTH_POLICY_KIND_COUNT || now_us < 0) {
return false;
}
for (unsigned i = 0; i < SSH_AUTH_POLICY_KIND_COUNT; ++i) {
if (policy->buckets[i].initialized && now_us < policy->buckets[i].last_seen_us) {
return false;
}
}
const unsigned capacity = kind == SSH_AUTH_POLICY_PROBE
? SSH_AUTH_POLICY_PROBE_CAPACITY
: kind == SSH_AUTH_POLICY_HANDSHAKE
? SSH_AUTH_POLICY_HANDSHAKE_CAPACITY : SSH_AUTH_POLICY_VERIFICATION_CAPACITY;
const int64_t interval = kind == SSH_AUTH_POLICY_PROBE
? SSH_AUTH_POLICY_PROBE_REFILL_US
: kind == SSH_AUTH_POLICY_HANDSHAKE
? SSH_AUTH_POLICY_HANDSHAKE_REFILL_US : SSH_AUTH_POLICY_VERIFICATION_REFILL_US;
ssh_auth_policy_bucket_t *bucket = &policy->buckets[kind];
if (!bucket->initialized) {
bucket->tokens = (uint8_t)capacity;
bucket->refill_us = now_us;
bucket->initialized = true;
} else {
/* Both timestamps are nonnegative and ordered. Divide before adding
* to avoid overflow even for a jump from zero to INT64_MAX. */
const int64_t elapsed = now_us - bucket->refill_us;
const int64_t earned = elapsed / interval;
if (earned >= (int64_t)(capacity - bucket->tokens)) {
bucket->tokens = (uint8_t)capacity;
bucket->refill_us = now_us;
} else {
bucket->tokens += (uint8_t)earned;
bucket->refill_us = now_us - elapsed % interval;
}
}
bucket->last_seen_us = now_us;
if (bucket->tokens == 0) {
return false;
}
--bucket->tokens;
return true;
}
+44
View File
@@ -0,0 +1,44 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdbool.h>
#include <stdint.h>
#define SSH_AUTH_POLICY_HANDSHAKE_CAPACITY 6U
#define SSH_AUTH_POLICY_HANDSHAKE_REFILL_US INT64_C(10000000)
#define SSH_AUTH_POLICY_VERIFICATION_CAPACITY 6U
#define SSH_AUTH_POLICY_VERIFICATION_REFILL_US INT64_C(10000000)
#define SSH_AUTH_POLICY_PROBE_CAPACITY 12U
#define SSH_AUTH_POLICY_PROBE_REFILL_US INT64_C(5000000)
typedef enum {
SSH_AUTH_POLICY_HANDSHAKE,
SSH_AUTH_POLICY_VERIFICATION,
SSH_AUTH_POLICY_PROBE,
SSH_AUTH_POLICY_KIND_COUNT
} ssh_auth_policy_kind_t;
typedef struct {
int64_t refill_us;
int64_t last_seen_us;
uint8_t tokens;
bool initialized;
} ssh_auth_policy_bucket_t;
typedef struct {
ssh_auth_policy_bucket_t buckets[SSH_AUTH_POLICY_KIND_COUNT];
} ssh_auth_policy_t;
/* Single-owner only: no allocation, locks, clock reads, timers or sleeps.
* Start zero-initialized; each class lazily starts at capacity. The owner must
* retain one shared instance across sessions, stop/start and counter clears;
* only reboot resets it. Do not modify fields directly or refund admissions.
* Each true result consumes one token, regardless of subsequent auth outcome.
* Refill adds one token per class-specific interval, preserving partial credit
* below capacity and discarding all surplus (including fractions) at capacity.
* now_us must be nonnegative and nondecreasing across ALL classes (equal is OK).
* NULL, invalid kind, negative time and regression reject without mutation.
* An empty-bucket denial records time but never postpones the refill deadline.
*/
bool ssh_auth_policy_admit(ssh_auth_policy_t *policy,
ssh_auth_policy_kind_t kind, int64_t now_us);
+10
View File
@@ -138,6 +138,16 @@ static int show_counters(void)
counter->handshake_successes, counter->handshake_failures,
counter->handshake_timeouts, counter->authentication_attempts,
counter->authentication_failures, counter->request_rejections);
printf("Auth admission: handshakes=%" PRIu64 " handshake-throttled=%" PRIu64
" verifications=%" PRIu64 " verification-throttled=%" PRIu64 "\n",
counter->handshake_admissions, counter->handshake_throttle_rejections,
counter->authentication_admissions, counter->authentication_throttle_rejections);
printf("Auth policy: probes=%" PRIu64 " probe-throttled=%" PRIu64
" attempt-limit-closes=%" PRIu64 " backend-errors=%" PRIu64
" method-rejects=%" PRIu64 "\n",
counter->authentication_probe_admissions, counter->authentication_probe_rejections,
counter->authentication_limit_disconnects, counter->authentication_backend_errors,
counter->authentication_method_rejections);
printf("Broker: connect=%" PRIu64 " failures=%" PRIu64
" disconnect=%" PRIu64 " writer-requests=%" PRIu64
" grants=%" PRIu64 " denials=%" PRIu64
+62
View File
@@ -0,0 +1,62 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_memory.h"
#include <string.h>
#include "sdkconfig.h"
#include "esp_heap_caps.h"
#include "esp_idf_version.h"
#include "secure_random.h"
#if !defined(CONFIG_HEAP_POISONING_DISABLED) || !CONFIG_HEAP_POISONING_DISABLED || \
(defined(CONFIG_HEAP_POISONING_LIGHT) && CONFIG_HEAP_POISONING_LIGHT) || \
(defined(CONFIG_HEAP_POISONING_COMPREHENSIVE) && CONFIG_HEAP_POISONING_COMPREHENSIVE)
#error "SSH memory requires heap poisoning disabled"
#endif
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 3)
#error "Reaudit SSH memory usable extent contract for this IDF"
#endif
void *ssh_memory_malloc(size_t size)
{
return heap_caps_malloc_prefer(size, 2,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
}
void ssh_memory_free(void *pointer)
{
if (pointer == NULL) {
return;
}
secure_wipe(pointer, heap_caps_get_allocated_size(pointer));
heap_caps_free(pointer);
}
void *ssh_memory_realloc(void *pointer, size_t size)
{
if (pointer == NULL) {
return ssh_memory_malloc(size);
}
if (size == 0U) {
ssh_memory_free(pointer);
return NULL;
}
/* Audited unpoisoned IDF 5.5.3 reports the owned usable extent, including
* rounding. Do not substitute an interior-pointer/block-containing query. */
size_t capacity = heap_caps_get_allocated_size(pointer);
if (size <= capacity) {
secure_wipe((unsigned char *)pointer + size, capacity - size);
return pointer;
}
void *replacement = ssh_memory_malloc(size);
if (replacement == NULL) {
return NULL;
}
memcpy(replacement, pointer, capacity);
ssh_memory_free(pointer);
return replacement;
}
+31
View File
@@ -0,0 +1,31 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Install these hooks before wolfSSH/wolfSSL allocations begin. No module state,
* locks or allocation headers. Free/realloc accept only live allocation BASE
* pointers from this allocator (or NULL), never interior pointers.
*
* PSRAM is preferred, with internal 8-bit heap fallback. malloc(0) follows the
* SDK allocator. free wipes the full owned usable extent. realloc(NULL, size)
* delegates to malloc; realloc(non-NULL, 0) securely frees and returns NULL.
*
* Realloc within the usable extent retains the pointer AND capacity: it wipes
* [size, capacity), but does not reclaim memory. Growth copies the entire old
* usable extent, not merely the original requested size. Both allocations are
* live until copying finishes; failure leaves the old allocation unchanged.
* This is heap-retirement cleanup, not a guarantee about live library buffers
* or stack secrets. Only the guarded, audited unpoisoned IDF build is supported.
*/
void *ssh_memory_malloc(size_t size);
void ssh_memory_free(void *pointer);
void *ssh_memory_realloc(void *pointer, size_t size);
#ifdef __cplusplus
}
#endif
+37
View File
@@ -0,0 +1,37 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_protocol_policy.h"
#include <stddef.h>
#include <wolfssh/error.h>
#include <wolfssh/version.h>
#if LIBWOLFSSH_VERSION_HEX != 0x01004020
#error "Re-audit SSH protocol policy and algorithm-list contracts for this wolfSSH version"
#endif
/* wolfSSH borrows these pointers in both contexts and sessions. */
static const char s_kex[] = "curve25519-sha256,ecdh-sha2-nistp256";
static const char s_host_key[] = "ecdsa-sha2-nistp256";
static const char s_cipher[] = "aes128-gcm@openssh.com,aes256-gcm@openssh.com";
static const char s_mac[] = "hmac-sha2-256";
/* server-sig-algs advertisement only; the user database enforces enrollment. */
static const char s_key_accepted[] = "ssh-ed25519,ecdsa-sha2-nistp256";
int ssh_protocol_policy_apply(WOLFSSH_CTX *context)
{
if (context == NULL) {
return WS_SSH_CTX_NULL_E;
}
int result = wolfSSH_CTX_SetAlgoListKex(context, s_kex);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListKey(context, s_host_key);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListCipher(context, s_cipher);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListMac(context, s_mac);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListKeyAccepted(context, s_key_accepted);
if (result != WS_SUCCESS) return result;
return WS_SUCCESS;
}
+10
View File
@@ -0,0 +1,10 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <wolfssh/ssh.h>
/* Apply before publishing the context or creating sessions. Returns WS_SUCCESS
* or the first setter error (WS_SSH_CTX_NULL_E for NULL). Failure may leave a
* partially configured context: the caller must discard it, never fall back to
* defaults. Does not allocate, free, or publish the context. */
int ssh_protocol_policy_apply(WOLFSSH_CTX *context);
+79 -41
View File
@@ -48,6 +48,9 @@ static bool s_mutex_creating;
static ssh_security_blob_t s_material;
static bool s_material_ready;
static ssh_security_load_result_t s_load_result;
static uint32_t s_identity_token, s_next_identity_token;
static TaskHandle_t s_identity_owner;
static bool s_identity_used;
static bool bytes_are_zero(const uint8_t *data, size_t size)
{
@@ -338,6 +341,10 @@ esp_err_t ssh_security_init(ssh_security_load_result_t *load_result)
return ESP_OK;
}
if (s_identity_token) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
ssh_security_blob_t candidate;
bool missing = false;
error = load_blob(&candidate, &missing);
@@ -411,60 +418,91 @@ esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata)
return error;
}
esp_err_t ssh_security_rotate(void)
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot)
{
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
ssh_security_blob_t candidate;
memset(&candidate, 0, sizeof(candidate));
if (s_material_ready && s_material.generation != UINT32_MAX) {
error = generate_blob(&candidate, s_material.generation + 1U);
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
if (error == ESP_OK) {
error = save_blob(&candidate);
snapshot->metadata.generation = s_material.generation;
memcpy(snapshot->metadata.sha256_fingerprint, s_material.sha256_fingerprint,
sizeof(snapshot->metadata.sha256_fingerprint));
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
}
if (error == ESP_OK) {
install_blob(&candidate);
}
}
secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t ssh_security_reset(void)
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token)
{
if (!token || (reset && generation)) return ESP_ERR_INVALID_ARG;
*token = 0;
if (reset) {
esp_err_t error = secure_random_init();
if (error != ESP_OK) {
return error;
if (error == ESP_OK) error = ensure_mutex();
if (error != ESP_OK) return error;
}
error = ensure_mutex();
if (error != ESP_OK) {
return error;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
uint32_t generation = 1U;
if (s_material_ready) {
if (s_material.generation == UINT32_MAX) {
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_identity_token || s_next_identity_token == UINT32_MAX ||
(!s_material_ready && !reset) ||
(s_material_ready && s_material.generation == UINT32_MAX) ||
(generation && generation != s_material.generation)) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
generation = s_material.generation + 1U;
}
ssh_security_blob_t candidate;
error = generate_blob(&candidate, generation);
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_blob(&candidate);
}
secure_wipe(&candidate, sizeof(candidate));
*token = s_identity_token = ++s_next_identity_token;
s_identity_owner = xTaskGetCurrentTaskHandle();
s_identity_used = false;
xSemaphoreGive(s_security_mutex);
return ESP_OK;
}
esp_err_t ssh_security_replace_reserved(uint32_t token)
{
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token != token || s_identity_used ||
s_identity_owner != xTaskGetCurrentTaskHandle()) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
s_identity_used = true;
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
xSemaphoreGive(s_security_mutex);
/* Reservation excludes writers while crypto and flash run outside locks. */
ssh_security_blob_t candidate = {0};
esp_err_t error = generate_blob(&candidate, generation);
if (error == ESP_OK) error = save_blob(&candidate);
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (error == ESP_OK) install_blob(&candidate);
xSemaphoreGive(s_security_mutex);
secure_wipe(&candidate, sizeof(candidate));
return error;
}
void ssh_security_release_identity(uint32_t token)
{
if (!s_security_mutex || !token) return;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token == token && s_identity_owner == xTaskGetCurrentTaskHandle()) {
s_identity_token = 0;
s_identity_owner = NULL;
}
xSemaphoreGive(s_security_mutex);
}
static esp_err_t replace_identity(bool reset)
{
uint32_t token = 0;
esp_err_t error = ssh_security_reserve_identity(0, reset, &token);
if (error == ESP_OK) error = ssh_security_replace_reserved(token);
ssh_security_release_identity(token);
return error;
}
esp_err_t ssh_security_rotate(void) { return replace_identity(false); }
esp_err_t ssh_security_reset(void) { return replace_identity(true); }
+17 -1
View File
@@ -3,6 +3,7 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
@@ -38,7 +39,22 @@ esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
size_t *output_length);
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata);
/* Caller must stop SSH first. Rotation requires valid live material; reset replaces any stored state. */
typedef struct {
ssh_security_metadata_t metadata;
bool busy;
} ssh_security_identity_snapshot_t;
/* Zero-wait atomic public projection; no private material. */
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot);
/* Owner transaction: nonreused token, reserve before side effects and retain through
* restart. Only the reserving task may replace once and release. Zero generation
* selects canonical semantics; reset additionally permits unavailable material. */
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token);
esp_err_t ssh_security_replace_reserved(uint32_t token);
void ssh_security_release_identity(uint32_t token);
/* Rotation requires valid live material; reset replaces any stored state.
* Direct callers share the reservation but do not restart the transport. */
esp_err_t ssh_security_rotate(void);
esp_err_t ssh_security_reset(void);
+245 -80
View File
@@ -10,7 +10,7 @@
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
@@ -24,10 +24,23 @@
#include "secure_random.h"
#include "serial_service.h"
#include "ssh_security.h"
#include "ssh_auth_policy.h"
#include "ssh_memory.h"
#include "ssh_protocol_policy.h"
#include "user_database.h"
#include <wolfssl/wolfcrypt/memory.h>
#include <wolfssl/wolfcrypt/random.h>
#include <wolfssh/ssh.h>
#include <wolfssh/version.h>
/* Admission precedes signature work; result callbacks only follow authorized
* signed keys. Re-audit the parser/callback contract when upgrading wolfSSH. */
#if LIBWOLFSSH_VERSION_HEX != 0x01004020
#error "Re-audit SSH authentication callback ordering for this wolfSSH version"
#endif
#if defined(WOLFSSH_CERTS) || defined(WOLFSSH_ALLOW_USERAUTH_NONE)
#error "SSH admission policy requires certificate and none authentication disabled"
#endif
#if defined(CONFIG_MBEDTLS_HARDWARE_AES) && CONFIG_MBEDTLS_HARDWARE_AES
#error "Concurrent mbedTLS/wolfSSH operation requires mbedTLS software AES"
@@ -64,6 +77,7 @@ typedef struct {
user_principal_t pending_principal;
bool principal_valid;
bool pending_principal_valid;
bool awaiting_auth_result;
bool authenticated;
bool shell_requested;
uint8_t console_slot_index;
@@ -100,6 +114,8 @@ static bool s_running;
static bool s_transitioning;
static bool s_desired_running;
static bool s_cleanup_pending;
/* Saturates independently of the internal completion sequence; never reset by counters. */
static uint32_t s_management_generation = 1U;
static uint32_t s_requested_sequence;
static uint32_t s_completed_sequence;
static esp_err_t s_command_result = ESP_ERR_INVALID_STATE;
@@ -108,11 +124,14 @@ static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
/* Owned exclusively by the transport task. */
static WOLFSSH_CTX *s_context;
static int s_listen_fd = -1;
/* Boot-lifetime admission state: neither service restart nor counter clear
* replenishes it. Only the SSH owner accesses this fixed-size policy. */
static ssh_auth_policy_t s_auth_policy;
static void add_counter(uint64_t *counter, uint64_t value)
{
taskENTER_CRITICAL(&s_lock);
*counter += value;
*counter = value > UINT64_MAX - *counter ? UINT64_MAX : *counter + value;
taskEXIT_CRITICAL(&s_lock);
}
@@ -290,15 +309,6 @@ static const admin_console_owner_t s_admin_console_owner = {
.perform = admin_console_perform,
};
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH) {
return ESP_ERR_INVALID_ARG;
}
return admin_ssh_console_open_owned(token, principal, &s_admin_console_owner);
}
static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
{
taskENTER_CRITICAL(&s_lock);
@@ -315,24 +325,6 @@ static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
return requested;
}
static void *ssh_malloc(size_t size)
{
return heap_caps_malloc_prefer(size, 2,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
}
static void ssh_free(void *pointer)
{
heap_caps_free(pointer);
}
static void *ssh_realloc(void *pointer, size_t size)
{
return heap_caps_realloc_prefer(pointer, size, 2,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
}
static int ssh_seed(OS_Seed *seed, byte *output, word32 size)
{
@@ -391,9 +383,52 @@ static void clear_pending_principal(ssh_slot_t *slot)
if (slot != NULL) {
secure_wipe(&slot->pending_principal, sizeof(slot->pending_principal));
slot->pending_principal_valid = false;
slot->awaiting_auth_result = false;
}
}
static void close_authentication(ssh_slot_t *slot)
{
if (slot != NULL && !slot->close_requested) {
slot->close_requested = true;
if (slot->socket_fd >= 0) {
(void)shutdown(slot->socket_fd, SHUT_RDWR);
}
}
}
static bool admit_authentication(ssh_slot_t *slot, bool probe)
{
ssh_auth_policy_kind_t kind = probe ? SSH_AUTH_POLICY_PROBE
: SSH_AUTH_POLICY_VERIFICATION;
if (!ssh_auth_policy_admit(&s_auth_policy, kind, esp_timer_get_time())) {
add_counter(probe ? &s_counters.authentication_probe_rejections
: &s_counters.authentication_throttle_rejections, 1U);
clear_pending_principal(slot);
close_authentication(slot);
return false;
}
add_counter(probe ? &s_counters.authentication_probe_admissions
: &s_counters.authentication_admissions, 1U);
return true;
}
/* Advertisement is not a dispatch filter in wolfSSH 1.4.20. Supply a rejecting
* callback so a direct keyboard-interactive request cannot call through NULL. */
static int reject_keyboard_auth(WS_UserAuthData_Keyboard *keyboard, void *context)
{
if (keyboard != NULL) {
secure_wipe(keyboard, sizeof(*keyboard));
}
ssh_slot_t *slot = (ssh_slot_t *)context;
if (slot != NULL && !slot->close_requested) {
add_counter(&s_counters.authentication_method_rejections, 1U);
}
clear_pending_principal(slot);
close_authentication(slot);
return WS_ERROR;
}
static bool complete_authentication_attempt(ssh_slot_t *slot, bool failed)
{
add_counter(&s_counters.authentication_attempts, 1U);
@@ -408,10 +443,8 @@ static bool complete_authentication_attempt(ssh_slot_t *slot, bool failed)
return true;
}
slot->close_requested = true;
if (slot->socket_fd >= 0) {
(void)shutdown(slot->socket_fd, SHUT_RDWR);
}
add_counter(&s_counters.authentication_limit_disconnects, 1U);
close_authentication(slot);
return false;
}
@@ -437,10 +470,14 @@ static int authenticate_password(ssh_slot_t *slot,
slot->principal = principal;
slot->principal_valid = true;
slot->authenticated = true;
secure_wipe(&principal, sizeof(principal));
return WOLFSSH_USERAUTH_SUCCESS;
}
secure_wipe(&principal, sizeof(principal));
if (error != ESP_OK) {
add_counter(&s_counters.authentication_backend_errors, 1U);
}
bool retry = complete_authentication_attempt(slot, true);
if (!retry) {
return WOLFSSH_USERAUTH_REJECTED;
@@ -470,6 +507,9 @@ static int authenticate_public_key(ssh_slot_t *slot,
if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
if (error != ESP_OK) {
add_counter(&s_counters.authentication_backend_errors, 1U);
}
if (public_key->hasSignature == 0U) {
return error == ESP_OK ? WOLFSSH_USERAUTH_INVALID_PUBLICKEY
: WOLFSSH_USERAUTH_FAILURE;
@@ -485,6 +525,7 @@ static int authenticate_public_key(ssh_slot_t *slot,
if (public_key->hasSignature != 0U) {
slot->pending_principal = principal;
slot->pending_principal_valid = true;
slot->awaiting_auth_result = true;
}
secure_wipe(&principal, sizeof(principal));
return WOLFSSH_USERAUTH_SUCCESS;
@@ -495,20 +536,29 @@ static int authenticate_user(byte authentication_type,
void *context)
{
ssh_slot_t *slot = (ssh_slot_t *)context;
if (slot == NULL || authentication == NULL ||
authentication_type != authentication->type) {
if (slot == NULL || slot->state != SSH_TRANSPORT_SESSION_HANDSHAKE ||
slot->close_requested || slot->authenticated || slot->awaiting_auth_result) {
clear_pending_principal(slot);
return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
close_authentication(slot);
return WOLFSSH_USERAUTH_REJECTED;
}
if (authentication_type == WOLFSSH_USERAUTH_PASSWORD) {
return authenticate_password(slot, authentication);
}
if (authentication_type == WOLFSSH_USERAUTH_PUBLICKEY) {
return authenticate_public_key(slot, authentication);
if (authentication == NULL || authentication_type != authentication->type ||
(authentication_type != WOLFSSH_USERAUTH_PASSWORD &&
authentication_type != WOLFSSH_USERAUTH_PUBLICKEY)) {
add_counter(&s_counters.authentication_method_rejections, 1U);
clear_pending_principal(slot);
close_authentication(slot);
return WOLFSSH_USERAUTH_REJECTED;
}
clear_pending_principal(slot);
return WOLFSSH_USERAUTH_INVALID_AUTHTYPE;
bool probe = authentication_type == WOLFSSH_USERAUTH_PUBLICKEY &&
authentication->sf.publicKey.hasSignature == 0U;
if (!admit_authentication(slot, probe)) {
return WOLFSSH_USERAUTH_REJECTED;
}
return authentication_type == WOLFSSH_USERAUTH_PASSWORD
? authenticate_password(slot, authentication)
: authenticate_public_key(slot, authentication);
}
static int authentication_result(byte result, WS_UserAuthData *authentication,
@@ -517,10 +567,17 @@ static int authentication_result(byte result, WS_UserAuthData *authentication,
ssh_slot_t *slot = (ssh_slot_t *)context;
if (slot == NULL || authentication == NULL ||
authentication->type != WOLFSSH_USERAUTH_PUBLICKEY ||
authentication->sf.publicKey.hasSignature == 0U) {
authentication->sf.publicKey.hasSignature == 0U ||
slot->state != SSH_TRANSPORT_SESSION_HANDSHAKE ||
slot->close_requested || slot->authenticated ||
!slot->awaiting_auth_result || !slot->pending_principal_valid) {
clear_pending_principal(slot);
close_authentication(slot);
return WS_ERROR;
}
/* The authorization callback already consumed the verification token.
* Take the completion marker before any result/currentness processing. */
slot->awaiting_auth_result = false;
if (result != WOLFSSH_USERAUTH_SUCCESS) {
(void)complete_authentication_attempt(slot, true);
@@ -534,6 +591,9 @@ static int authentication_result(byte result, WS_UserAuthData *authentication,
&slot->pending_principal, &current)
: ESP_ERR_INVALID_STATE;
if (error != ESP_OK || !current) {
if (error != ESP_OK) {
add_counter(&s_counters.authentication_backend_errors, 1U);
}
(void)complete_authentication_attempt(slot, true);
clear_pending_principal(slot);
return WS_ERROR;
@@ -613,7 +673,7 @@ static bool cleanup_slot(ssh_slot_t *slot)
}
uint32_t generation = slot->generation;
memset(slot, 0, sizeof(*slot));
secure_wipe(slot, sizeof(*slot));
slot->state = SSH_TRANSPORT_SESSION_FREE;
slot->generation = generation;
slot->socket_fd = -1;
@@ -665,10 +725,17 @@ static esp_err_t create_context(void)
return ESP_FAIL;
}
result = ssh_protocol_policy_apply(context);
if (result != WS_SUCCESS) {
wolfSSH_CTX_free(context);
return ESP_FAIL;
}
wolfSSH_SetIORecv(context, bounded_ssh_receive);
wolfSSH_SetUserAuth(context, authenticate_user);
wolfSSH_SetUserAuthTypes(context, allowed_auth_types);
wolfSSH_SetUserAuthResult(context, authentication_result);
wolfSSH_SetKeyboardAuthPrompts(context, reject_keyboard_auth);
(void)wolfSSH_CTX_SetChannelReqShellCb(context, accept_shell);
(void)wolfSSH_CTX_SetChannelReqExecCb(context, reject_channel_request);
(void)wolfSSH_CTX_SetChannelReqSubsysCb(context, reject_channel_request);
@@ -705,6 +772,11 @@ static esp_err_t create_listener(void)
static esp_err_t start_runtime(void)
{
/* Never overwrite an orphaned context/listener or sessions after failed stop. */
if (s_context != NULL || s_listen_fd >= 0) return ESP_ERR_INVALID_STATE;
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
if (s_slots[index].state != SSH_TRANSPORT_SESSION_FREE) return ESP_ERR_INVALID_STATE;
}
esp_err_t error = create_context();
if (error == ESP_OK) {
error = create_listener();
@@ -750,7 +822,7 @@ static esp_err_t stop_runtime(void)
break;
}
}
if (s_context != NULL) {
if (all_free && s_context != NULL) {
wolfSSH_CTX_free(s_context);
s_context = NULL;
}
@@ -837,7 +909,8 @@ static ssh_slot_t *find_free_slot(size_t *slot_index)
}
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE) {
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE &&
s_slots[index].generation < SSH_TRANSPORT_GENERATION_MAX) {
*slot_index = index;
return &s_slots[index];
}
@@ -898,6 +971,13 @@ static void accept_connections(void)
close(socket_fd);
continue;
}
if (!ssh_auth_policy_admit(&s_auth_policy, SSH_AUTH_POLICY_HANDSHAKE,
esp_timer_get_time())) {
add_counter(&s_counters.handshake_throttle_rejections, 1U);
close(socket_fd);
continue;
}
add_counter(&s_counters.handshake_admissions, 1U);
if (set_nonblocking(socket_fd) != ESP_OK) {
add_counter(&s_counters.io_failures, 1U);
close(socket_fd);
@@ -907,7 +987,8 @@ static void accept_connections(void)
(void)setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY,
&enabled, sizeof(enabled));
uint32_t generation = next_generation(slot->generation);
/* Exhausted slots are retired by find_free_slot(), never reused after wrap. */
uint32_t generation = slot->generation + 1U;
memset(slot, 0, sizeof(*slot));
slot->state = SSH_TRANSPORT_SESSION_HANDSHAKE;
slot->generation = generation;
@@ -927,6 +1008,7 @@ static void accept_connections(void)
wolfSSH_SetIOReadCtx(slot->ssh, slot);
wolfSSH_SetUserAuthCtx(slot->ssh, slot);
wolfSSH_SetUserAuthResultCtx(slot->ssh, slot);
wolfSSH_SetKeyboardAuthCtx(slot->ssh, slot);
wolfSSH_SetChannelReqCtx(slot->ssh, slot);
publish_slot(slot, slot_index);
}
@@ -1224,6 +1306,11 @@ static bool flush_client_output(ssh_slot_t *slot)
slot->ssh, slot->tx_buffer + slot->tx_offset,
(word32)(slot->tx_length - slot->tx_offset));
if (result > 0) {
/* Positive stream_send means copied/consumed by wolfSSH, not peer
* receipt. Preserve pending bytes on retry and the binary serial path. */
if (slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
secure_wipe(slot->tx_buffer + slot->tx_offset, (size_t)result);
}
slot->tx_offset += (size_t)result;
add_counter(&s_counters.tx_bytes, (uint64_t)result);
if (slot->tx_offset >= slot->tx_length) {
@@ -1285,6 +1372,7 @@ static bool flush_admin_input(ssh_slot_t *slot, size_t slot_index)
&token, slot->rx_buffer + slot->rx_offset,
slot->rx_length - slot->rx_offset, &consumed);
if (consumed > 0U) {
secure_wipe(slot->rx_buffer + slot->rx_offset, consumed);
slot->rx_offset += consumed;
add_counter(&s_counters.rx_accepted_bytes, consumed);
}
@@ -1389,10 +1477,18 @@ static void process_slots(void)
}
if (all_free) {
taskENTER_CRITICAL(&s_lock);
if (!s_running) {
s_cleanup_pending = false;
}
bool stopped = !s_running;
taskEXIT_CRITICAL(&s_lock);
/* The owner alone retires the retained context, before reopening admission. */
if (stopped) {
if (s_context != NULL) {
wolfSSH_CTX_free(s_context);
s_context = NULL;
}
taskENTER_CRITICAL(&s_lock);
s_cleanup_pending = false;
taskEXIT_CRITICAL(&s_lock);
}
}
}
@@ -1434,7 +1530,7 @@ esp_err_t ssh_transport_init(void)
error = ESP_ERR_INVALID_STATE;
goto fail;
}
if (wolfSSL_SetAllocators(ssh_malloc, ssh_free, ssh_realloc) != 0) {
if (wolfSSL_SetAllocators(ssh_memory_malloc, ssh_memory_free, ssh_memory_realloc) != 0) {
error = ESP_FAIL;
goto fail;
}
@@ -1512,6 +1608,7 @@ static esp_err_t request_running_locked(bool desired)
return ESP_OK;
}
s_desired_running = desired;
if (s_management_generation != UINT32_MAX) ++s_management_generation;
s_transitioning = true;
s_requested_sequence = next_generation(s_requested_sequence);
sequence = s_requested_sequence;
@@ -1558,46 +1655,54 @@ esp_err_t ssh_transport_stop(void)
return request_running(false);
}
esp_err_t ssh_transport_replace_host_key(bool reset)
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
uint32_t identity_generation,
bool reset, bool *committed)
{
if (s_command_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_command_mutex, portMAX_DELAY);
if (!committed || (!!service_generation != !!identity_generation) ||
(reset && service_generation) || service_generation == UINT32_MAX ||
identity_generation == UINT32_MAX) return ESP_ERR_INVALID_ARG;
*committed = false;
if (!s_command_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
bool was_running;
bool cleanup_pending;
taskENTER_CRITICAL(&s_lock);
if (!s_initialized || s_transitioning) {
bool valid = s_initialized && !s_transitioning &&
(!service_generation || (!s_cleanup_pending && service_generation == s_management_generation));
bool was_running = s_running, cleanup_pending = s_cleanup_pending;
taskEXIT_CRITICAL(&s_lock);
xSemaphoreGive(s_command_mutex);
return ESP_ERR_INVALID_STATE;
}
was_running = s_running;
cleanup_pending = s_cleanup_pending;
taskEXIT_CRITICAL(&s_lock);
esp_err_t error = ESP_OK;
if (was_running || cleanup_pending) {
error = request_running_locked(false);
}
uint32_t token = 0;
esp_err_t error = valid ? ssh_security_reserve_identity(identity_generation, reset, &token)
: ESP_ERR_INVALID_STATE;
if (error == ESP_OK) {
error = reset ? ssh_security_reset() : ssh_security_rotate();
}
if (error != ESP_OK) {
if (was_running) {
taskENTER_CRITICAL(&s_lock);
if (s_management_generation != UINT32_MAX) ++s_management_generation;
taskEXIT_CRITICAL(&s_lock);
/* Keep the service mutex and identity reservation through stop/replace/start.
* Failed stop must never mutate identity or attempt another start. */
if (was_running || cleanup_pending) error = request_running_locked(false);
if (error == ESP_OK) {
error = ssh_security_replace_reserved(token);
*committed = error == ESP_OK;
if (error != ESP_OK && was_running) {
/* Stop succeeded: restore service using unchanged committed material. */
(void)request_running_locked(true);
}
xSemaphoreGive(s_command_mutex);
return error;
}
if (was_running || reset) {
} else if (error == ESP_OK && (was_running || reset)) {
error = request_running_locked(true);
}
}
}
ssh_security_release_identity(token);
xSemaphoreGive(s_command_mutex);
return error;
}
esp_err_t ssh_transport_replace_host_key(bool reset)
{
bool committed;
return ssh_transport_replace_identity(0, 0, reset, &committed);
}
esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
{
if (snapshot == NULL) {
@@ -1633,6 +1738,66 @@ esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
return ESP_OK;
}
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot)
{
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
taskENTER_CRITICAL(&s_lock);
if (!s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
memset(snapshot, 0, sizeof(*snapshot));
snapshot->generation = s_management_generation;
snapshot->running = s_running;
snapshot->transitioning = s_transitioning || s_cleanup_pending;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
snapshot->sessions[i] = s_session_snapshots[i];
snapshot->sessions[i].close_requested |=
snapshot->sessions[i].active && s_external_close_id[i] == snapshot->sessions[i].session_id;
}
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
uint32_t target, uint32_t generation)
{
if (!generation || generation == UINT32_MAX ||
action < SSH_TRANSPORT_MANAGE_START || action > SSH_TRANSPORT_MANAGE_DISCONNECT ||
((action == SSH_TRANSPORT_MANAGE_DISCONNECT) != (target != 0U))) return ESP_ERR_INVALID_ARG;
if (s_command_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = ESP_ERR_INVALID_STATE;
taskENTER_CRITICAL(&s_lock);
if (s_initialized && !s_transitioning && !s_cleanup_pending &&
generation == s_management_generation) {
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) {
error = ESP_ERR_NOT_FOUND;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *session = &s_session_snapshots[i];
if (session->active && session->session_id == target &&
!session->close_requested && session->state != SSH_TRANSPORT_SESSION_CLOSING &&
s_external_close_id[i] != target) {
s_external_close_id[i] = target;
error = ESP_OK;
break;
}
}
} else if (s_running != (action == SSH_TRANSPORT_MANAGE_START)) {
error = ESP_OK;
}
}
taskEXIT_CRITICAL(&s_lock);
/* The command mutex spans comparison and canonical lifecycle admission.
* No HTTPD work/lock is involved; only the SSH owner touches sockets/wolfSSH. */
if (error == ESP_OK) {
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) notify_task();
else error = request_running_locked(action == SSH_TRANSPORT_MANAGE_START);
}
xSemaphoreGive(s_command_mutex);
return error;
}
esp_err_t ssh_transport_clear_counters(void)
{
taskENTER_CRITICAL(&s_lock);
+39
View File
@@ -44,6 +44,17 @@ typedef struct {
uint64_t handshake_timeouts;
uint64_t authentication_attempts;
uint64_t authentication_failures;
/* Admission is before work; completion above excludes denied requests and
* unsigned probes. All values are counts, never submitted identity data. */
uint64_t handshake_admissions;
uint64_t handshake_throttle_rejections;
uint64_t authentication_admissions;
uint64_t authentication_throttle_rejections;
uint64_t authentication_probe_admissions;
uint64_t authentication_probe_rejections;
uint64_t authentication_limit_disconnects;
uint64_t authentication_backend_errors;
uint64_t authentication_method_rejections;
uint64_t request_rejections;
uint64_t broker_connections;
uint64_t broker_failures;
@@ -99,11 +110,39 @@ typedef struct {
ssh_transport_counters_t counters;
} ssh_transport_snapshot_t;
typedef enum {
SSH_TRANSPORT_MANAGE_START = 0,
SSH_TRANSPORT_MANAGE_STOP,
SSH_TRANSPORT_MANAGE_DISCONNECT,
} ssh_transport_management_action_t;
typedef struct {
uint32_t generation;
bool running;
bool transitioning;
ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS];
} ssh_transport_management_snapshot_t;
/* Compact published state only; no wolfSSH calls or task-stack scan. */
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot);
/* Dispatcher-only conditional admission; success on disconnect means owner notified,
* not peer receipt/cleanup. Lifecycle timeout does not cancel admitted work. */
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
uint32_t target, uint32_t generation);
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
esp_err_t ssh_transport_init(void);
esp_err_t ssh_transport_start(void);
esp_err_t ssh_transport_stop(void);
/* Conditional off-HTTPD rotation: both generations checked/reserved before stop.
* Zero generations retain canonical rotate/reset semantics. A failed stop skips
* mutation/start; persistence failure may already have disconnected all SSH.
* committed reports irreversible publication even if restart subsequently fails. */
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
uint32_t identity_generation,
bool reset, bool *committed);
/* Serialize stop, persistent host-key replacement, and conditional restart. */
esp_err_t ssh_transport_replace_host_key(bool reset);
+21 -8
View File
@@ -11,6 +11,7 @@
#include "admin_ssh_console.h"
#include "console_input.h"
#include "esp_console.h"
#include "esp_heap_caps.h"
#include "mbedtls/base64.h"
#include "secure_random.h"
#include "ssh_transport.h"
@@ -19,8 +20,9 @@
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
/* `user` commands are serialized by the administration gate. */
static user_database_snapshot_t s_user_snapshot;
/* Gate-owned public projection (no verifiers). Lazily retained for firmware
* lifetime; allocation failure must not disable mutations or UART0 recovery. */
static user_database_snapshot_t *s_user_snapshot;
static void print_usage(void)
{
@@ -92,21 +94,30 @@ static void print_user(const user_database_user_snapshot_t *user)
static int show_users(const char *selected)
{
esp_err_t error = user_database_get_snapshot(&s_user_snapshot);
if (s_user_snapshot == NULL) {
s_user_snapshot = heap_caps_malloc(sizeof(*s_user_snapshot),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (s_user_snapshot == NULL) {
printf("User status unavailable: PSRAM snapshot storage unavailable\n");
return 1;
}
}
esp_err_t error = user_database_get_snapshot(s_user_snapshot);
if (error != ESP_OK) {
printf("User database unavailable: %s\n", esp_err_to_name(error));
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
return 1;
}
if (selected == NULL) {
printf("User database: generation=%lu users=%u/%u admins=%u\n",
(unsigned long)s_user_snapshot.generation,
(unsigned int)s_user_snapshot.user_count,
(unsigned long)s_user_snapshot->generation,
(unsigned int)s_user_snapshot->user_count,
USER_DATABASE_MAX_USERS,
(unsigned int)s_user_snapshot.admin_count);
(unsigned int)s_user_snapshot->admin_count);
}
bool found = false;
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
const user_database_user_snapshot_t *user = &s_user_snapshot.users[index];
const user_database_user_snapshot_t *user = &s_user_snapshot->users[index];
if (!user->active ||
(selected != NULL &&
(strlen(selected) != user->username_length ||
@@ -118,11 +129,13 @@ static int show_users(const char *selected)
}
if (selected != NULL && !found) {
printf("User '%s' not found.\n", selected);
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
return 1;
}
if (s_user_snapshot.admin_count == 0U) {
if (s_user_snapshot->admin_count == 0U) {
printf("No administrators; use 'user add <username> admin' on UART0.\n");
}
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
return 0;
}
+2 -5
View File
@@ -109,11 +109,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
* Commit before restart; a failed stop retains HTTPD ownership and must
* not be followed by start. No socket IO or console handler calls here. */
esp_err_t error = web_security_rotate_certificate();
if (error != ESP_OK) return error;
error = web_server_stop();
if (error != ESP_OK) return error;
return web_server_start();
bool committed = false;
return web_server_replace_identity(0, 0, false, &committed);
}
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
if (action == ADMIN_SSH_DEFER_REBOOT) {
+234
View File
@@ -0,0 +1,234 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_broker_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "session_broker.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
uint32_t generation, target;
unsigned state;
} broker_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static broker_operation_t s_operation;
static uint32_t s_next_id;
/* Narrow flat JSON: exact action plus two unsigned decimal integers, no
* escapes, duplicates, unknown fields, nesting, fractions or exponents. */
static bool parse(const char *body, size_t length, broker_operation_t *operation)
{
const char *keys[] = {"action", "generation", "target"};
unsigned seen = 0;
size_t pos = 0;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 3; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 3; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 3 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
const char action[] = "\"assign\"";
if (length - pos < sizeof(action) - 1 || memcmp(body + pos, action, sizeof(action) - 1)) return false;
pos += sizeof(action) - 1;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
else operation->target = number;
}
seen |= 1U << key;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == 7 && operation->target &&
operation->generation && operation->generation != UINT32_MAX;
}
void web_broker_settings_execute(uint32_t id)
{
broker_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
error = session_broker_assign_writer_current(operation.target, operation.generation);
state = error == ESP_OK ? OK :
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_broker_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
broker_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_broker_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_broker_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? "assign" : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_broker_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
session_broker_management_snapshot_t snapshot;
error = session_broker_get_management_snapshot(&snapshot);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"broker_unavailable\"}");
goto done;
}
/* Eight rows; names are exact bounded bytes as hex, never unescaped JSON.
* Decimal-string drop counters retain all 64 bits in the browser. */
char response[2048];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"writer\":%" PRIu32 ",\"clients\":[",
snapshot.generation, snapshot.writer_id);
size_t used = 0;
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
used = (size_t)written;
for (size_t i = 0; i < snapshot.count; ++i) {
const session_broker_management_client_t *client = &snapshot.clients[i];
char name[SESSION_BROKER_CLIENT_NAME_MAX * 2 + 1];
static const char hex[] = "0123456789abcdef";
size_t n = 0;
for (; n < SESSION_BROKER_CLIENT_NAME_MAX && client->name[n]; ++n) {
unsigned byte = (unsigned char)client->name[n];
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
}
name[n * 2] = 0;
written = snprintf(response + used, sizeof(response) - used,
"%s{\"id\":%" PRIu32 ",\"type\":%u,\"name_hex\":\"%s\",\"pending\":%u,\"high_water\":%u,\"dropped\":\"%" PRIu64 "\"}",
i ? "," : "", client->id, (unsigned)client->type, name,
(unsigned)client->pending, (unsigned)client->high_water, client->dropped);
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
used += (size_t)written;
}
written = snprintf(response + used, sizeof(response) - used, "]}");
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only snapshot and login-isolated typed assignment/results. */
esp_err_t web_broker_settings_handler(httpd_req_t *request);
esp_err_t web_broker_operation_handler(httpd_req_t *request);
void web_broker_settings_execute(uint32_t id);
+57 -51
View File
@@ -24,6 +24,7 @@ static void print_usage(void)
printf(" web status|start|stop\n");
printf(" web counters|clear-counters\n");
printf(" web diagnostics enable|disable|show|clear\n");
printf(" web performance enable|disable|show|clear\n");
printf(" web certificate info\n");
printf(" web certificate rotate --force\n");
printf(" web reset --force (TLS certificate and private key only)\n");
@@ -233,65 +234,65 @@ static bool force_is_present(int argc, char **argv, int expected_argc)
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
}
static int restart_if_running(bool was_running)
static int replace_material(bool reset)
{
if (!was_running) {
return 0;
}
esp_err_t error = web_server_stop();
bool committed = false;
esp_err_t error = web_server_replace_identity(0, 0, reset, &committed);
if (error != ESP_OK) {
printf("Material changed, but the old TLS server could not stop: %s\n",
printf("%s: %s\n", committed
? "New HTTPS identity persisted, but stop/start failed; no rollback. Inspect via UART0 before retrying"
: "HTTPS identity replacement rejected or failed before publication",
esp_err_to_name(error));
return 1;
}
error = web_server_start();
if (error != ESP_OK) {
printf("Material changed, but HTTPS could not restart: %s\n",
esp_err_to_name(error));
return 1;
}
return 0;
}
static int rotate_certificate(void)
{
web_server_snapshot_t snapshot;
esp_err_t error = web_server_get_snapshot(&snapshot);
if (error != ESP_OK) {
printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error));
return 1;
}
error = web_security_rotate_certificate();
if (error != ESP_OK) {
printf("Could not rotate web certificate: %s\n", esp_err_to_name(error));
return 1;
}
printf("Web certificate and private key rotated and persisted.\n");
return restart_if_running(snapshot.running);
}
static int reset_material(void)
{
web_server_snapshot_t snapshot;
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running;
esp_err_t error = web_security_reset_all();
if (error != ESP_OK) {
printf("Could not reset web security material: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
if (was_running) {
return restart_if_running(true);
}
printf("Verify the new fingerprint via trusted UART0, renew browser trust, and sign in again.\n");
return 0;
}
error = web_server_start();
if (error != ESP_OK) {
printf("Security material recovered, but HTTPS could not start: %s\n",
esp_err_to_name(error));
static int rotate_certificate(void) { return replace_material(false); }
static int reset_material(void) { return replace_material(true); }
static void print_performance_time(const char *name, const web_serial_performance_timing_t *t)
{
printf(" %s: count=%" PRIu64 " sum_us=%" PRIu64 " avg_us_est=%" PRIu64 " max_us=%" PRIu64 "\n",
name, t->count, t->sum_us, t->count ? t->sum_us / t->count : 0, t->max_us);
}
static int performance_command(const char *action)
{
esp_err_t result = ESP_OK;
if (!strcmp(action, "enable")) result = web_serial_performance_enable(true);
else if (!strcmp(action, "disable")) result = web_serial_performance_enable(false);
else if (!strcmp(action, "clear")) result = web_serial_performance_clear();
else if (strcmp(action, "show")) return 1;
if (result != ESP_OK) {
printf("Web performance: %s\n", esp_err_to_name(result));
return 1;
}
printf("HTTPS started with the recovered security material.\n");
web_serial_performance_snapshot_t s;
web_serial_performance_snapshot(&s);
printf("Web performance: enabled=%u epoch=%" PRIu32 " epoch_exhausted=%u; binary TX only\n",
s.enabled, s.epoch, s.epoch_exhausted);
printf("Send-call return is synchronous HTTPD-owner bytes send, not peer receipt. Timings are instrumented estimates; saturated=1 invalidates averages/count totals.\n");
printf("Completion->nonempty includes idle gaps; first-nonempty excludes observed empty attempts, not proof of backlog.\n");
for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
const web_serial_performance_session_t *r = &s.sessions[i];
if (!r->active) continue;
printf("slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32
" pending=%u measured_pending=%u executing=%u pending_age_us=%" PRIu64 " saturated=%u\n",
i, r->socket_fd, r->generation, (uint32_t)r->broker_client_id,
r->pending, r->measured_pending, r->executing, r->pending_age_us, r->saturated);
printf(" queued_frames=%" PRIu64 " queued_bytes=%" PRIu64 " queue_errors=%" PRIu64
" sent_frames=%" PRIu64 " sent_bytes=%" PRIu64 " send_errors=%" PRIu64 " retired=%" PRIu64 "\n",
r->queued_frames, r->queued_bytes, r->queue_errors, r->sent_frames,
r->sent_bytes, r->send_errors, r->retired);
print_performance_time("queue->callback-entry", &r->queue_wait);
print_performance_time("send-call", &r->send_call);
print_performance_time("completion->first-drain-attempt-return", &r->completion_attempt);
print_performance_time("completion->next-nonempty-drain-return (includes idle)", &r->completion_nonempty);
print_performance_time("completion->first-attempt-nonempty-return", &r->completion_first_nonempty);
}
return 0;
}
@@ -301,6 +302,11 @@ static int command_web(int argc, char **argv)
print_usage();
return 0;
}
if (argc == 3 && strcmp(argv[1], "performance") == 0) {
if (performance_command(argv[2]) == 0) return 0;
print_usage();
return 1;
}
if (argc == 3 && strcmp(argv[1], "diagnostics") == 0) {
if (web_diagnostics_command(argv[2]) == 0) return 0;
print_usage();
@@ -368,7 +374,7 @@ static int command_web(int argc, char **argv)
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n");
printf("HTTPS identity rotation scheduled after console output drains; all web logins and browser terminals will close. A new identity may persist even if stop/start fails; no rollback. Verify the new fingerprint via trusted UART0 web certificate info before renewing browser trust, then reload and sign in. SSH and USB UART1 access remain independent.\n");
return 0;
}
return rotate_certificate();
+36 -19
View File
@@ -115,6 +115,26 @@ static esp_err_t failure(httpd_req_t *r, const char *status, const char *code)
return response(r, status, body);
}
/* An early probe never charges or advances the verification window. Only the
* post-parse reservation commits it; retry == 0 means budget is available. */
static bool verification_budget(uint64_t epoch, bool reserve, int64_t *retry)
{
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
bool ready = s_ready && epoch == s_epoch;
int64_t window = s_window;
unsigned attempts = s_attempts;
if (now - window >= WINDOW_US) { window = now; attempts = 0; }
*retry = attempts < 5 ? 0 : (window + WINDOW_US - now + 999999LL) / 1000000LL;
if (ready && reserve && attempts < 5) {
s_window = window;
s_attempts = attempts + 1;
++s_counts.login_attempts;
}
taskEXIT_CRITICAL(&s_lock);
return ready;
}
static esp_err_t capacity(httpd_req_t *r)
{
if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
@@ -248,7 +268,8 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
bool document = !strcmp(r->uri, "/login");
web_session_view_t view = {0};
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
char set_cookie[180] = {0}, body[513] = {0};
char set_cookie[180] = {0}, body[513] = {0}, retry_after[16] = {0};
int64_t retry = 0;
web_auth_credentials_t credentials = {0};
challenge_t candidate = {0};
uint8_t digest[32] = {0}, origin_digest[32] = {0};
@@ -361,6 +382,10 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
}
if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; }
if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup;
if (!verification_budget(epoch, false, &retry)) {
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
}
if (retry) goto throttled;
size_t received = 0;
int64_t deadline = now + 3000000LL;
while (received < r->content_len && esp_timer_get_time() < deadline) {
@@ -369,29 +394,15 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
received += (size_t)count;
}
if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny;
now = esp_timer_get_time();
unsigned attempts;
int64_t retry;
taskENTER_CRITICAL(&s_lock);
ready = s_ready && epoch == s_epoch;
if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; }
attempts = s_attempts;
if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; }
retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL;
taskEXIT_CRITICAL(&s_lock);
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (attempts >= 5) {
char seconds[16];
snprintf(seconds, sizeof(seconds), "%lld", (long long)retry);
if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup;
result = failure(r, "429 Too Many Requests", "throttled");
goto cleanup;
secure_wipe(body, sizeof(body));
if (!verification_budget(epoch, true, &retry)) {
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
}
if (retry) goto throttled;
bool authenticated = false;
user_principal_t principal = {0};
esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length,
credentials.password, credentials.password_length, &principal, &authenticated);
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
if (error == ESP_OK && authenticated)
error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view);
@@ -406,7 +417,13 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
result = response(r, "200 OK", "{\"authenticated\":true}");
if (result != ESP_OK) web_session_store_invalidate(view.id);
goto cleanup;
throttled:
status = "429 Too Many Requests"; code = "throttled";
snprintf(retry_after, sizeof(retry_after), "%lld", (long long)retry);
if (httpd_resp_set_hdr(r, "Retry-After", retry_after) != ESP_OK) goto cleanup;
deny:
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
result = failure(r, status, code);
cleanup:
if (challenge_published && result != ESP_OK) {
+236
View File
@@ -0,0 +1,236 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_display_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "local_status_ui.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { APPLY, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
static const char *const s_actions[] = {"apply", "save", "load", "defaults", "reset"};
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, CONFLICT };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "conflict"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
local_ui_config_t config;
uint32_t generation;
unsigned action, state;
} display_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static display_operation_t s_operation;
static uint32_t s_next_id;
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
static bool parse(const char *body, size_t length, display_operation_t *operation)
{
const char *keys[] = {"action", "generation", "dim_seconds", "off_seconds"};
unsigned seen = 0;
size_t pos = 0;
local_ui_config_defaults(&operation->config);
operation->action = ACTION_COUNT;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 4; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 4; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 4 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (strlen(s_actions[i]) == pos - start && !memcmp(body + start, s_actions[i], pos - start)) operation->action = i;
if (operation->action == ACTION_COUNT) return false;
++pos;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
if (key == 2) operation->config.dim_timeout_seconds = number;
if (key == 3) operation->config.off_timeout_seconds = number;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == APPLY ? 15U : 3U) &&
operation->generation != 0 && local_ui_config_validate(&operation->config) == ESP_OK;
}
void web_display_settings_execute(uint32_t id)
{
display_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* The owner checks the selected generation and reserves all config
* mutations, including CLI callers, across storage IO. Buttons only
* signal activity: they never replace configuration or own this gate. */
bool defaults = false;
static const local_ui_settings_action_t actions[] = {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
};
error = local_status_ui_update_settings(actions[operation.action], operation.generation,
&operation.config, &defaults);
state = error == ESP_OK ? (defaults ? LOADED_DEFAULTS : OK) :
error == ESP_ERR_INVALID_STATE ? CONFLICT : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
secure_wipe(&s_operation.config, sizeof(s_operation.config));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_display_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
display_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_display_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_display_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_display_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
local_ui_config_t config;
uint32_t generation;
error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"display_unavailable\"}");
goto done;
}
char response[128];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"dim_seconds\":%" PRIu32 ",\"off_seconds\":%" PRIu32 "}",
generation, config.dim_timeout_seconds, config.off_timeout_seconds);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only RAM snapshot and typed dispatcher admission/results. */
esp_err_t web_display_settings_handler(httpd_req_t *request);
esp_err_t web_display_operation_handler(httpd_req_t *request);
void web_display_settings_execute(uint32_t id);
+52 -4
View File
@@ -10,11 +10,11 @@
#include "esp_httpd_priv.h"
#include "secure_random.h"
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0)
#error "Reaudit HTTPD headers, upgrade, request completion and idle cleanup for this IDF"
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 3)
#error "Reaudit HTTPD headers, upgrade, idle cleanup and WS/TLS send contracts for this IDF"
#endif
/* IDF 5.5.0 httpd_sess_process increments lru_counter only AFTER successful
/* IDF 5.5.3 httpd_sess_process increments lru_counter only AFTER successful
* req_new + req_delete (handler, response, leftover-body purge and cleanup).
* Work callbacks run between sessions, never inside synchronous parse/TLS/send.
* This counter is an observation marker, NOT permission to enable LRU purge. */
@@ -60,6 +60,54 @@ void web_httpd_idle_sweep(httpd_handle_t server,
}
}
static int web_httpd_aborted_send(httpd_handle_t server, int fd,
const char *buffer, size_t length, int flags)
{
(void)server; (void)fd; (void)buffer; (void)length; (void)flags;
return HTTPD_SOCK_ERR_FAIL;
}
esp_err_t web_httpd_ws_send_binary(httpd_handle_t server, int fd,
const void *expected_context,
const uint8_t *payload, size_t length)
{
struct httpd_data *hd = server;
if (!hd || fd < 0 || !expected_context || (!payload && length) ||
length > WEB_HTTPD_WS_BINARY_MAX_PAYLOAD) return ESP_ERR_INVALID_ARG;
if (httpd_os_thread_handle() != hd->hd_td.handle || hd->hd_req_aux.sd)
return ESP_ERR_INVALID_STATE;
struct sock_db *sd = httpd_sess_get(hd, fd);
if (!sd || sd->ctx != expected_context || !sd->ws_handshake_done ||
sd->ws_close || sd->for_async_req || !sd->send_fn)
return ESP_ERR_INVALID_STATE;
/* IDF 5.5.3 httpd_ws_send_frame_async emits header/payload separately.
* Keep the existing send override (HTTPS -> esp_tls_conn_write), not raw
* socket IO. Owner-local scratch lives through the synchronous call only. */
uint8_t wire[WEB_HTTPD_WS_BINARY_MAX_PAYLOAD + 4U];
size_t header = length <= 125U ? 2U : 4U;
wire[0] = 0x82; /* FIN, binary; server frames are never masked. */
wire[1] = header == 2U ? (uint8_t)length : 126U;
if (header == 4U) {
wire[2] = (uint8_t)(length >> 8U);
wire[3] = (uint8_t)length;
}
if (length) memcpy(wire + header, payload, length);
size_t total = header + length;
int sent = sd->send_fn(hd, fd, (const char *)wire, total, 0);
if (sent == (int)total) return ESP_OK;
/* Owner retains this validated session across the synchronous send. TLS
* may hold pending output after short/zero/WANT/error: never retry it with
* different arguments. Deferred close alone permits SDK automatic PONG or
* CLOSE first. Shutdown alone cannot block buffered-input TLS calls either.
* Reject all sends before shutdown, even if shutdown fails. ws_close also
* skips SDK request processing; normal HTTPD still owns TLS destruction. */
sd->send_fn = web_httpd_aborted_send;
sd->ws_close = true;
(void)shutdown(fd, SHUT_RDWR);
return ESP_FAIL;
}
bool web_httpd_headers_valid(httpd_req_t *request)
{
if (!request || !request->aux) return false;
@@ -171,7 +219,7 @@ esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *
return ESP_ERR_INVALID_STATE;
}
if (slot < 0) return ESP_ERR_NO_MEM;
/* IDF 5.5.0 publishes its descriptor before strdup; strdup failure leaves a
/* IDF 5.5.3 publishes its descriptor before strdup; strdup failure leaves a
* freed hd_calls entry. Optional registration must leave the table intact. */
httpd_uri_t *copy = malloc(sizeof(*copy));
if (!copy) return ESP_ERR_NO_MEM;
+9
View File
@@ -26,6 +26,15 @@ void web_httpd_wipe_request(httpd_req_t *request, bool closing);
esp_err_t web_httpd_upgrade(httpd_req_t *request,
esp_err_t (*handler)(httpd_req_t *));
#define WEB_HTTPD_WS_BINARY_MAX_PAYLOAD 512U
/* HTTPD-owner work only; caller retains generation/lifecycle and one-work-slot
* ownership. Synchronous FIN/binary/unmasked send through the session override.
* Non-full writes synchronously block further session sends and abort the socket;
* caller must retain normal failure/cleanup accounting, never replay the frame. */
esp_err_t web_httpd_ws_send_binary(httpd_handle_t server, int fd,
const void *expected_context,
const uint8_t *payload, size_t length);
/* Serialized server startup only, exact-match ordinary GET, URI <= 127 bytes.
* Stage both allocations before publication; HTTPD owns/frees them on success. */
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri);
+283
View File
@@ -0,0 +1,283 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_lifecycle_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "web_server.h"
#include "web_security.h"
#if CONFIG_HTTPD_QUEUE_WORK_BLOCKING
#error "Lifecycle ACK handoff requires nonblocking HTTPD work submission"
#endif
enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED };
static const char *const s_states[] = {"idle", "pending", "pending", "ok", "failed", "cancelled"};
static const char *const s_actions[] = {"stop", "restart", "reboot", "rotate"};
typedef struct {
uint32_t id, generation, identity_generation;
web_session_id_t session;
user_principal_t principal;
int64_t ack_deadline, deadline;
unsigned action, state;
bool queued;
} lifecycle_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static lifecycle_operation_t s_operation;
static uint32_t s_next_id, s_ack_id;
/* Comparison only; never dereferenced outside the invoking HTTPD handler. */
static httpd_handle_t s_ack_server;
static void cancel_locked(void)
{
s_operation.state = CANCELLED;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
static void expire_locked(int64_t now)
{
if (s_operation.state == PENDING &&
now >= (s_operation.queued ? s_operation.deadline : s_operation.ack_deadline))
cancel_locked();
}
/* Exactly action + service generation, plus identity generation only for rotate.
* No escapes, duplicates, coercions or extra fields. */
static bool parse(const char *body, size_t length, lifecycle_operation_t *operation)
{
const char *keys[] = {"action", "generation", "identity_generation"};
unsigned seen = 0;
size_t pos = 0;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 3; ++field) {
if (field) { TAKE(','); }
TAKE('"'); size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 3; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 3 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned action = 0;
for (; action < 4; ++action)
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
if (action == 4) return false;
operation->action = action; ++pos;
} else {
uint32_t number = 0; start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
else operation->identity_generation = number;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == 3 ? 7U : 3U) &&
operation->generation && operation->generation != UINT32_MAX &&
(operation->action != 3 || (operation->identity_generation && operation->identity_generation != UINT32_MAX));
}
/* Runs on HTTPD after its synchronous response handler returns. No socket IO,
* wait, authorization or lifecycle call here. A duplicate/late ID is inert. */
static void ack_handoff(void *argument)
{
uint32_t id = (uint32_t)(uintptr_t)argument;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
bool submit = id && s_ack_id == id;
if (submit) {
s_ack_id = 0; s_ack_server = NULL;
expire_locked(now);
submit = s_operation.id == id && s_operation.state == PENDING;
if (submit) s_operation.queued = true;
}
taskEXIT_CRITICAL(&s_lock);
if (submit && admin_ssh_console_submit_lifecycle_settings(id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) cancel_locked();
taskEXIT_CRITICAL(&s_lock);
}
}
void web_lifecycle_settings_stopped(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
if (server && s_ack_server == server) { s_ack_id = 0; s_ack_server = NULL; }
/* Successful shutdown invalidates all old logins. Executing work owns its
* slot until return, including its deliberately session-invalidating stop. */
if (s_operation.state == PENDING) cancel_locked();
taskEXIT_CRITICAL(&s_lock);
}
void web_lifecycle_settings_execute(uint32_t id)
{
lifecycle_operation_t operation = {0};
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
bool execute = id && s_operation.id == id && s_operation.state == PENDING && s_operation.queued;
if (execute) {
s_operation.state = EXECUTING;
operation = s_operation;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
taskEXIT_CRITICAL(&s_lock);
if (!execute) return;
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
bool committed = false;
error = operation.action == 0 ? web_server_stop_current(operation.generation) :
operation.action == 1 ? web_server_restart_current(operation.generation) :
operation.action == 2 ? web_server_reboot_current(operation.generation) :
web_server_replace_identity(operation.generation, operation.identity_generation, false, &committed);
/* Even INVALID_STATE can be a detach failure after stop admission. */
state = error == ESP_OK ? OK : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == EXECUTING) s_operation.state = state;
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_lifecycle_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
lifecycle_operation_t operation = {0};
bool allowed = false, mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done;
}
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) { error = respond(request, "400 Bad Request", "{\"error\":\"invalid_lifecycle_request\"}"); goto done; }
operation.session = view.id; operation.principal = view.principal;
int64_t now = esp_timer_get_time();
operation.ack_deadline = now + 2000000LL;
operation.deadline = now + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
bool busy = s_ack_id || s_operation.state == PENDING || s_operation.state == EXECUTING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id; s_operation = operation;
s_ack_id = operation.id; s_ack_server = request->handle;
}
taskEXIT_CRITICAL(&s_lock);
if (busy) { error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); goto done; }
} else {
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_operation.session == view.id) {
operation.id = s_operation.id; operation.state = s_operation.state; operation.action = s_operation.action;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
if (mutation) {
/* No lifecycle can run before this send returns and HTTPD hands off.
* Successful send is not peer receipt. Never retry queue submission. */
if (error != ESP_OK || httpd_queue_work(request->handle, ack_handoff, (void *)(uintptr_t)operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (s_ack_id == operation.id) { s_ack_id = 0; s_ack_server = NULL; }
if (s_operation.id == operation.id && s_operation.state == PENDING) cancel_locked();
taskEXIT_CRITICAL(&s_lock);
}
}
done:
secure_wipe(&operation, sizeof(operation));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_lifecycle_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done;
}
web_server_management_snapshot_t snapshot;
if (web_server_get_management_snapshot(&snapshot) != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"lifecycle_unavailable\"}"); goto done;
}
web_security_identity_snapshot_t identity = {0};
bool available = web_security_get_identity_snapshot(&identity) == ESP_OK;
char fingerprint[65] = {0};
if (available) {
for (size_t i = 0; i < sizeof(identity.fingerprint); ++i)
snprintf(fingerprint + i * 2, 3, "%02x", identity.fingerprint[i]);
}
char response[320];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s,"
"\"identity_generation\":%" PRIu32 ",\"fingerprint\":\"%s\",\"rotatable\":%s}",
snapshot.generation, snapshot.running ? "true" : "false",
snapshot.transitioning ? "true" : "false", snapshot.controllable ? "true" : "false",
available ? identity.generation : 0, fingerprint,
available && snapshot.controllable && !identity.busy && identity.generation &&
identity.generation != UINT32_MAX ? "true" : "false");
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+11
View File
@@ -0,0 +1,11 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
esp_err_t web_lifecycle_settings_handler(httpd_req_t *request);
esp_err_t web_lifecycle_operation_handler(httpd_req_t *request);
/* Existing dispatcher only; callbacks submit IDs, never execute lifecycle work. */
void web_lifecycle_settings_execute(uint32_t id);
/* Only after successful HTTPD destruction, before another server can start. */
void web_lifecycle_settings_stopped(httpd_handle_t server);
+446
View File
@@ -0,0 +1,446 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_network_settings.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mdns_service.h"
#include "secure_random.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "wifi_manager.h"
enum { WIFI_PATCH, PROFILE_PATCH, WIFI_SAVE, WIFI_LOAD, START, STOP, RECONNECT,
NEXT_PROFILE, MDNS_SET, MDNS_SAVE, MDNS_LOAD, MDNS_DEFAULTS, ACTION_COUNT };
static const char *const s_actions[] = {"wifi-patch", "profile-patch", "wifi-save", "wifi-load",
"start", "stop", "reconnect", "next-profile", "mdns-set", "mdns-save", "mdns-load", "mdns-defaults"};
enum { IDLE, PENDING, ACCEPTED, OK, FAILED, CANCELLED, STALE, INVALID,
LOADED_DEFAULTS, APPLIED_NOT_QUEUED };
static const char *const s_states[] = {"idle", "pending", "accepted", "ok", "failed", "cancelled",
"stale", "invalid", "loaded_defaults", "applied_not_queued"};
typedef struct {
uint32_t id, generation;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
unsigned action, state;
esp_err_t error;
bool executing;
wifi_manager_patch_t patch;
mdns_config_t mdns;
} network_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static network_operation_t s_operation;
static uint32_t s_next_id;
static esp_timer_handle_t s_secret_timer;
static bool s_secret_timer_started;
static void wipe_input(network_operation_t *operation)
{
secure_wipe(&operation->principal, sizeof(operation->principal));
secure_wipe(&operation->patch, sizeof(operation->patch));
secure_wipe(&operation->mdns, sizeof(operation->mdns));
operation->generation = 0;
}
static void expire_input(void *unused)
{
(void)unused;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
if (s_operation.state == PENDING && !s_operation.executing && now >= s_operation.deadline) {
s_operation.state = CANCELLED;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
}
static bool ensure_secret_timer(void)
{
/* Sole HTTPD admission owner; one firmware-lifetime timer, no extra task.
* Periodic inspection avoids an old captured expiry cancelling a newer ID. */
if (!s_secret_timer) {
const esp_timer_create_args_t args = {.callback = expire_input, .name = "network-input"};
if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false;
}
if (!s_secret_timer_started) {
if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false;
s_secret_timer_started = true;
}
return true;
}
typedef struct { const char *body; size_t size, pos; } parser_t;
static void space(parser_t *p)
{
while (p->pos < p->size && (p->body[p->pos] == ' ' || p->body[p->pos] == '\r' ||
p->body[p->pos] == '\n' || p->body[p->pos] == '\t')) ++p->pos;
}
static bool take(parser_t *p, char c)
{
space(p);
return p->pos < p->size && p->body[p->pos++] == c;
}
static int hex_digit(unsigned char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
/* Bounded byte-string decoder, deliberately not Unicode-to-UTF8 conversion.
* See the public contract: \\u00ff is exactly one SSID byte, not two. */
static bool byte_string(parser_t *p, uint8_t *out, size_t capacity, size_t *length)
{
*length = 0;
if (!take(p, '"')) return false;
while (p->pos < p->size) {
unsigned char c = (unsigned char)p->body[p->pos++];
if (c == '"') return true;
if (c < 0x20 || c > 0x7e || *length == capacity) return false;
if (c == '\\') {
if (p->pos == p->size) return false;
c = (unsigned char)p->body[p->pos++];
switch (c) {
case '"': case '\\': case '/': break;
case 'b': c = '\b'; break;
case 'f': c = '\f'; break;
case 'n': c = '\n'; break;
case 'r': c = '\r'; break;
case 't': c = '\t'; break;
case 'u': {
if (p->size - p->pos < 4 || p->body[p->pos] != '0' || p->body[p->pos + 1] != '0') return false;
int high = hex_digit(p->body[p->pos + 2]), low = hex_digit(p->body[p->pos + 3]);
if (high < 0 || low < 0) return false;
c = (unsigned char)(high * 16 + low); p->pos += 4; break;
}
default: return false;
}
}
out[(*length)++] = c;
}
return false;
}
static bool number(parser_t *p, uint32_t *out)
{
space(p); size_t start = p->pos; *out = 0;
while (p->pos < p->size && p->body[p->pos] >= '0' && p->body[p->pos] <= '9') {
unsigned digit = (unsigned)(p->body[p->pos++] - '0');
if (*out > (UINT32_MAX - digit) / 10) return false;
*out = *out * 10 + digit;
}
return p->pos > start && (p->pos - start == 1 || p->body[start] != '0');
}
static bool boolean(parser_t *p, uint32_t *out)
{
space(p);
if (p->size - p->pos >= 4 && !memcmp(p->body + p->pos, "true", 4)) { p->pos += 4; *out = 1; return true; }
if (p->size - p->pos >= 5 && !memcmp(p->body + p->pos, "false", 5)) { p->pos += 5; *out = 0; return true; }
return false;
}
static bool parse_request(const char *body, size_t length, network_operation_t *operation)
{
enum { ACTION, GENERATION, PROFILE, ENABLED, PRIORITY, SECURITY, SSID, PASSWORD,
CLEAR_PASSWORD, BOOT, POLICY, CHANNEL, SUFFIX, KEY_COUNT };
static const char *const keys[] = {"action", "generation", "profile", "enabled", "priority", "security",
"ssid", "password", "clear_password", "enabled_at_boot", "ap_policy", "channel", "suffix"};
parser_t p = {.body = body, .size = length};
uint32_t seen = 0;
operation->action = ACTION_COUNT;
operation->patch.profile = -1;
if (!take(&p, '{')) return false;
for (unsigned field = 0; field < KEY_COUNT; ++field) {
uint8_t key_text[20] = {0}; size_t n;
if ((field && !take(&p, ',')) || !byte_string(&p, key_text, sizeof(key_text), &n)) return false;
unsigned key = 0;
for (; key < KEY_COUNT; ++key) if (strlen(keys[key]) == n && !memcmp(keys[key], key_text, n)) break;
if (key == KEY_COUNT || (seen & (1U << key)) || !take(&p, ':')) return false;
seen |= 1U << key;
uint32_t value = 0;
uint8_t text[64] = {0};
bool valid;
if (key == GENERATION || key == PROFILE || key == PRIORITY || key == CHANNEL) valid = number(&p, &value);
else if (key == ENABLED || key == CLEAR_PASSWORD || key == BOOT) valid = boolean(&p, &value);
else valid = byte_string(&p, text, sizeof(text) - 1, &n);
if (!valid) { secure_wipe(text, sizeof(text)); return false; }
switch (key) {
case ACTION:
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (strlen(s_actions[i]) == n && !memcmp(s_actions[i], text, n)) operation->action = i;
valid = operation->action != ACTION_COUNT; break;
case GENERATION: operation->generation = value; valid = value != 0; break;
case PROFILE: valid = value < WIFI_CONFIG_STA_PROFILE_COUNT; operation->patch.profile = (int8_t)value; break;
case ENABLED: operation->patch.enabled = value; operation->patch.fields |= WIFI_PATCH_ENABLED; break;
case PRIORITY: valid = value <= UINT8_MAX; operation->patch.priority = value; operation->patch.fields |= WIFI_PATCH_PRIORITY; break;
case SECURITY:
valid = !memchr(text, 0, n) && wifi_config_parse_security((char *)text, &operation->patch.security);
operation->patch.fields |= WIFI_PATCH_SECURITY; break;
case SSID:
valid = n <= WIFI_CONFIG_SSID_MAX_LEN;
if (valid) { memcpy(operation->patch.ssid, text, n); operation->patch.ssid_len = n; }
operation->patch.fields |= WIFI_PATCH_SSID; break;
case PASSWORD:
valid = n >= WIFI_CONFIG_PSK_MIN_LEN && n <= WIFI_CONFIG_PSK_MAX_LEN;
for (size_t i = 0; valid && i < n; ++i) valid = text[i] >= 0x20 && text[i] <= 0x7e;
if (valid) { memcpy(operation->patch.password, text, n); operation->patch.password_len = n; }
operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
case CLEAR_PASSWORD: valid = value == 1; operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
case BOOT: operation->patch.enabled_at_boot = value; operation->patch.fields |= WIFI_PATCH_BOOT; break;
case POLICY:
valid = !memchr(text, 0, n) && wifi_config_parse_ap_policy((char *)text, &operation->patch.ap_policy);
operation->patch.fields |= WIFI_PATCH_POLICY; break;
case CHANNEL: valid = value >= WIFI_CONFIG_AP_CHANNEL_MIN && value <= WIFI_CONFIG_AP_CHANNEL_MAX;
operation->patch.ap_channel = value; operation->patch.fields |= WIFI_PATCH_CHANNEL; break;
case SUFFIX:
valid = n <= MDNS_CONFIG_SUFFIX_MAX_LEN;
if (valid) {
operation->mdns.schema_version = MDNS_CONFIG_SCHEMA_VERSION;
operation->mdns.blob_size = MDNS_CONFIG_BLOB_SIZE;
operation->mdns.suffix_len = n; memcpy(operation->mdns.suffix, text, n);
valid = mdns_config_validate(&operation->mdns) == ESP_OK;
}
break;
}
secure_wipe(text, sizeof(text));
if (!valid) return false;
space(&p);
if (p.pos < p.size && p.body[p.pos] == '}') break;
}
if (!take(&p, '}')) return false;
space(&p);
if (p.pos != p.size || !(seen & 1U) ||
((seen & (1U << PASSWORD)) && (seen & (1U << CLEAR_PASSWORD)))) return false;
uint32_t required = 1U, allowed = 1U;
if (operation->action == WIFI_PATCH || operation->action == PROFILE_PATCH) {
required |= 1U << GENERATION;
allowed = required | (1U << SSID) | (1U << PASSWORD) | (1U << CLEAR_PASSWORD);
if (operation->action == PROFILE_PATCH) {
required |= 1U << PROFILE;
allowed |= (1U << PROFILE) | (1U << ENABLED) | (1U << PRIORITY) | (1U << SECURITY);
} else allowed |= (1U << BOOT) | (1U << POLICY) | (1U << CHANNEL);
if (!operation->patch.fields) return false;
} else if (operation->action == WIFI_SAVE || operation->action == WIFI_LOAD || operation->action >= MDNS_SET) {
required |= 1U << GENERATION;
if (operation->action == MDNS_SET) required |= 1U << SUFFIX;
allowed = required;
}
return operation->action < ACTION_COUNT && (seen & required) == required && !(seen & ~allowed);
}
void web_network_settings_execute(uint32_t id)
{
network_operation_t operation = {0};
taskENTER_CRITICAL(&s_lock);
bool claimed = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
if (claimed) {
s_operation.executing = true;
operation = s_operation;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
if (!claimed) return;
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
state = ACCEPTED;
switch (operation.action) {
case WIFI_PATCH: case PROFILE_PATCH: error = wifi_manager_patch_current(operation.generation, &operation.patch); break;
case WIFI_SAVE: error = wifi_manager_save_current(operation.generation); state = OK; break;
case WIFI_LOAD: error = wifi_manager_load_current(operation.generation); break;
case START: error = wifi_manager_start(); break;
case STOP: error = wifi_manager_stop(); break;
case RECONNECT: error = wifi_manager_reconnect(); break;
case NEXT_PROFILE: error = wifi_manager_next_profile(); break;
default: {
bool stored = true;
mdns_settings_action_t action = operation.action == MDNS_SET ? MDNS_SETTINGS_SET :
operation.action == MDNS_SAVE ? MDNS_SETTINGS_SAVE :
operation.action == MDNS_LOAD ? MDNS_SETTINGS_LOAD : MDNS_SETTINGS_DEFAULTS;
error = mdns_service_update_current(operation.generation, action, &operation.mdns, &stored);
if (error == ESP_OK) {
if (action == MDNS_SETTINGS_SAVE) state = OK;
else {
error = wifi_manager_mdns_reannounce();
state = error != ESP_OK ? APPLIED_NOT_QUEUED : stored ? ACCEPTED : LOADED_DEFAULTS;
}
}
break;
}
}
if (error != ESP_OK && state != APPLIED_NOT_QUEUED)
state = error == ESP_ERR_NOT_FOUND ? STALE : error == ESP_ERR_INVALID_ARG ? INVALID : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
s_operation.error = error;
s_operation.executing = false;
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
static esp_err_t authorize(httpd_req_t *request, bool mutation, web_session_view_t *view, bool *allowed)
{
esp_err_t error = mutation ? web_cookie_auth_require_json(request, WEB_NETWORK_REQUEST_MAX, view, allowed) :
web_cookie_auth_require(request, false, false, view, allowed);
if (error == ESP_OK && *allowed && view->principal.role != USER_ROLE_ADMIN) {
*allowed = false;
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
}
return error;
}
static bool append(char *out, size_t capacity, size_t *used, const char *format, ...)
{
va_list args; va_start(args, format);
int count = vsnprintf(out + *used, capacity - *used, format, args);
va_end(args);
if (count < 0 || (size_t)count >= capacity - *used) return false;
*used += (size_t)count; return true;
}
static bool append_ssid(char *out, size_t capacity, size_t *used, const uint8_t *ssid, size_t length)
{
if (length > WIFI_CONFIG_SSID_MAX_LEN || !append(out, capacity, used, "\"")) return false;
for (size_t i = 0; i < length; ++i) {
unsigned c = ssid[i];
if (c >= 0x20 && c <= 0x7e && c != '"' && c != '\\') {
if (!append(out, capacity, used, "%c", c)) return false;
} else if (!append(out, capacity, used, "\\u%04x", c)) return false;
}
return append(out, capacity, used, "\"");
}
static const char *json_bool(bool value) { return value ? "true" : "false"; }
static esp_err_t snapshot_response(httpd_req_t *request)
{
wifi_manager_settings_t wifi;
mdns_service_snapshot_t mdns;
/* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK)
return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}");
char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0;
#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0)
#define SSID(data, length) do { if (!append_ssid(response, sizeof(response), &used, data, length)) return ESP_FAIL; } while (0)
ADD("{\"wifi\":{\"generation\":%" PRIu32 ",\"enabled_at_boot\":%s,\"ap\":{\"policy\":\"%s\",\"channel\":%u,\"ssid\":",
wifi.runtime.config_generation, json_bool(wifi.enabled_at_boot),
wifi_config_ap_policy_to_string(wifi.ap_policy), (unsigned)wifi.ap_channel);
SSID(wifi.ap_ssid, wifi.ap_ssid_len);
ADD(",\"password_configured\":%s},\"profiles\":[", json_bool(wifi.ap_password_configured));
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
const wifi_manager_profile_settings_t *p = &wifi.profiles[i];
ADD("%s{\"index\":%u,\"enabled\":%s,\"priority\":%u,\"security\":\"%s\",\"ssid\":",
i ? "," : "", i, json_bool(p->enabled), (unsigned)p->priority, wifi_config_security_to_string(p->security));
SSID(p->ssid, p->ssid_len);
ADD(",\"password_configured\":%s}", json_bool(p->password_configured));
}
const wifi_manager_snapshot_t *r = &wifi.runtime;
/* IPv4 bytes are already in network order, independent of host endianness. */
const uint8_t *ip = (const uint8_t *)&r->ip;
ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\","
"\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d},",
json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile,
ip[0], ip[1], ip[2], ip[3], json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}",
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
#undef SSID
#undef ADD
return respond(request, "200 OK", response);
}
esp_err_t web_network_snapshot_handler(httpd_req_t *request)
{
web_session_view_t view = {0}; bool allowed = false;
esp_err_t error = authorize(request, false, &view, &allowed);
if (error == ESP_OK && allowed) {
error = request->method == HTTP_GET ? snapshot_response(request) :
respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}");
}
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_network_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0}; bool allowed = false;
network_operation_t operation = {0};
bool mutation = request->method == HTTP_POST;
esp_err_t error = authorize(request, mutation, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (request->method != HTTP_GET && !mutation) {
error = respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}"); goto done;
}
if (mutation) {
char type[40] = {0}, body[WEB_NETWORK_REQUEST_MAX]; size_t received = 0;
bool valid = request->content_len > 0 && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse_request(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
wipe_input(&operation);
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_network_request\"}"); goto done;
}
if (!ensure_secret_timer()) {
wipe_input(&operation);
error = respond(request, "503 Service Unavailable", "{\"error\":\"timer_unavailable\"}"); goto done;
}
operation.session = view.id; operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL; operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
taskEXIT_CRITICAL(&s_lock);
wipe_input(&operation);
if (busy || admin_ssh_console_submit_network_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id; operation.action = s_operation.action;
operation.state = s_operation.state; operation.error = s_operation.error;
}
taskEXIT_CRITICAL(&s_lock);
}
/* Input is not needed for formatting or potentially blocking socket IO. */
wipe_input(&operation);
char response[128];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\",\"error\":%d}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state], (int)operation.error);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
done:
secure_wipe(&operation, sizeof(operation));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+32
View File
@@ -0,0 +1,32 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
#define WEB_NETWORK_REQUEST_MAX 768U
#define WEB_NETWORK_SNAPSHOT_MAX 2048U
/* Integration: optional exact GET /api/settings/network -> snapshot_handler;
* exact GET and POST /api/settings/network-operation -> operation_handler.
* All require current admin cookie, same Origin; POST additionally CSRF/JSON.
* No query strings. No changes to browser-shell command authorization.
*
* One session-bound replaceable result, no durable history/idempotency. Only an
* ID enters the existing dispatcher. A periodic one-second ESP timer wipes and
* cancels non-executing input at 30 seconds plus scheduling latency. Executing
* locals wipe on return; already-admitted work can finish after session loss.
* 'accepted' means RAM/owner queue admission, NEVER association or DHCP success.
*
* SSID JSON is a BYTE string: raw printable ASCII, standard single-character
* JSON escapes, and \\u00HH only; each decoded codepoint maps to one byte. NUL and
* non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI
* must encode UTF-8 text into bytes before encoding this field, and retain a
* reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes.
* No saved PSK/length is returned, only password_configured. Omitted password
* preserves current bytes; clear_password:true is distinct from replacement.
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
* Wi-Fi Load is stored-only, no generated-default/reset/secret-delivery route.
*/
esp_err_t web_network_snapshot_handler(httpd_req_t *request);
esp_err_t web_network_operation_handler(httpd_req_t *request);
void web_network_settings_execute(uint32_t id);
+77 -53
View File
@@ -51,6 +51,8 @@ _Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
static SemaphoreHandle_t s_security_mutex;
static web_security_blob_t s_material;
static bool s_material_ready;
static uint32_t s_identity_token, s_next_identity_token;
static bool s_identity_used;
static web_security_load_result_t s_load_result;
static bool bytes_are_zero(const uint8_t *data, size_t size)
@@ -649,6 +651,10 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
if (s_material_ready) {
if (load_result != NULL) {
*load_result = s_load_result;
@@ -763,14 +769,6 @@ esp_err_t web_security_get_certificate_metadata(
return error;
}
static esp_err_t increment_generation(web_security_blob_t *blob)
{
if (blob->generation == UINT32_MAX) {
return ESP_ERR_INVALID_STATE;
}
++blob->generation;
return ESP_OK;
}
static void install_committed_blob(const web_security_blob_t *candidate)
{
@@ -781,64 +779,90 @@ static void install_committed_blob(const web_security_blob_t *candidate)
s_load_result = WEB_SECURITY_LOAD_STORED;
}
esp_err_t web_security_rotate_certificate(void)
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot)
{
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
web_security_blob_t candidate;
memset(&candidate, 0, sizeof(candidate));
if (s_material_ready) {
candidate = s_material;
error = increment_generation(&candidate);
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
if (error == ESP_OK) {
error = generate_certificate(&candidate);
snapshot->generation = s_material.generation;
memcpy(snapshot->fingerprint, s_material.certificate_fingerprint, sizeof(snapshot->fingerprint));
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
}
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
}
}
secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_reset_all(void)
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token)
{
if (!token || (reset && expected_generation)) return ESP_ERR_INVALID_ARG;
*token = 0;
if (!reset) {
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
bool ready = s_material_ready;
xSemaphoreGive(s_security_mutex);
if (!ready) return ESP_ERR_INVALID_STATE;
}
esp_err_t error = secure_random_init();
if (error != ESP_OK) {
return error;
}
error = ensure_security_mutex();
if (error != ESP_OK) {
return error;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
web_security_blob_t candidate;
uint32_t generation = 1U;
if (s_material_ready) {
if (s_material.generation == UINT32_MAX) {
if (error == ESP_OK) error = ensure_security_mutex();
if (error != ESP_OK) return error;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_identity_token || s_next_identity_token == UINT32_MAX ||
(!s_material_ready && !reset) ||
(s_material_ready && s_material.generation == UINT32_MAX) ||
(expected_generation && expected_generation != s_material.generation)) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
generation = s_material.generation + 1U;
}
*token = s_identity_token = ++s_next_identity_token;
error = generate_all(&candidate, generation);
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
}
secure_wipe(&candidate, sizeof(candidate));
s_identity_used = false;
xSemaphoreGive(s_security_mutex);
return ESP_OK;
}
esp_err_t web_security_replace_reserved(uint32_t token)
{
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token != token || s_identity_used) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
s_identity_used = true;
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
xSemaphoreGive(s_security_mutex);
/* The reservation, not a held mutex/spinlock, excludes all identity writers. */
web_security_blob_t candidate = {0};
esp_err_t error = generate_all(&candidate, generation);
if (error == ESP_OK) error = save_blob(&candidate);
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (error == ESP_OK) install_committed_blob(&candidate);
xSemaphoreGive(s_security_mutex);
secure_wipe(&candidate, sizeof(candidate));
return error;
}
void web_security_release_identity(uint32_t token)
{
if (!s_security_mutex || !token) return;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token == token) s_identity_token = 0;
xSemaphoreGive(s_security_mutex);
}
static esp_err_t replace_identity(bool reset)
{
uint32_t token = 0;
esp_err_t error = web_security_reserve_identity(0, reset, &token);
if (error == ESP_OK) error = web_security_replace_reserved(token);
web_security_release_identity(token);
return error;
}
esp_err_t web_security_rotate_certificate(void) { return replace_identity(false); }
esp_err_t web_security_reset_all(void) { return replace_identity(true); }
+17
View File
@@ -70,6 +70,23 @@ esp_err_t web_security_copy_tls_material(
esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata);
typedef struct {
uint32_t generation;
uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH];
bool busy;
} web_security_identity_snapshot_t;
/* Zero-wait public metadata only; never returns DER or private material. */
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot);
/* Internal owner transaction shared with canonical mutations. Tokens never reuse.
* Reserve before crypto; retain through service restart. No lock stays held.
* Zero expected_generation selects canonical CLI semantics; reset permits recovery.
* Only the reserving owner may replace once and release its token. */
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token);
esp_err_t web_security_replace_reserved(uint32_t token);
void web_security_release_identity(uint32_t token);
/* Mutations become visible only after a complete blob has committed to NVS. */
esp_err_t web_security_rotate_certificate(void);
+154 -1
View File
@@ -65,6 +65,9 @@ typedef struct {
uint32_t generation;
} web_serial_work_t;
_Static_assert(WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE == WEB_HTTPD_WS_BINARY_MAX_PAYLOAD,
"Reaudit bounded binary WS send when changing serial payload size");
typedef struct web_serial_slot {
web_serial_slot_state_t state;
httpd_handle_t server;
@@ -86,6 +89,10 @@ typedef struct web_serial_slot {
uint8_t rx_data[WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD];
uint8_t tx_data[WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE];
web_serial_work_t work;
web_serial_performance_session_t performance;
uint32_t performance_epoch, completion_epoch;
int64_t queued_us, completed_us;
bool completion_waiting, completion_attempted;
} web_serial_slot_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
@@ -103,6 +110,88 @@ static uint32_t s_inflight_handlers;
/* Cancels ticket publication across revocation and server detach/re-attach.
* Never wraps: exhaustion disables minting for the remainder of the boot. */
static uint64_t s_ticket_epoch;
/* Atomic gate permits a true pre-lock callback-entry timestamp. Never reused. */
static uint32_t s_performance_gate, s_performance_epoch;
static uint32_t performance_gate(void)
{
return __atomic_load_n(&s_performance_gate, __ATOMIC_RELAXED);
}
static void performance_add(web_serial_slot_t *slot, uint64_t *value, uint64_t n)
{
if (UINT64_MAX - *value < n) {
*value = UINT64_MAX;
slot->performance.saturated = true;
} else *value += n;
}
static void performance_time(web_serial_slot_t *slot,
web_serial_performance_timing_t *value,
int64_t start, int64_t end)
{
uint64_t elapsed = end >= start ? (uint64_t)(end - start) : 0;
performance_add(slot, &value->count, 1);
performance_add(slot, &value->sum_us, elapsed);
if (elapsed > value->max_us) value->max_us = elapsed;
}
static esp_err_t performance_control(bool enabled, bool clear)
{
taskENTER_CRITICAL(&s_lock);
if (clear) enabled = performance_gate() != 0;
if (s_performance_epoch == UINT32_MAX) {
__atomic_store_n(&s_performance_gate, 0, __ATOMIC_RELAXED);
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
++s_performance_epoch;
__atomic_store_n(&s_performance_gate, enabled ? s_performance_epoch : 0,
__ATOMIC_RELAXED);
for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
s_slots[i].completion_waiting = false;
if (clear) memset(&s_slots[i].performance, 0, sizeof(s_slots[i].performance));
}
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t web_serial_performance_enable(bool enabled)
{
return performance_control(enabled, false);
}
esp_err_t web_serial_performance_clear(void)
{
return performance_control(false, true);
}
esp_err_t web_serial_performance_snapshot(web_serial_performance_snapshot_t *out)
{
if (!out) return ESP_ERR_INVALID_ARG;
taskENTER_CRITICAL(&s_lock);
uint32_t gate = performance_gate();
int64_t now = gate ? esp_timer_get_time() : 0;
*out = (web_serial_performance_snapshot_t){
.enabled = gate != 0, .epoch = s_performance_epoch,
.epoch_exhausted = s_performance_epoch == UINT32_MAX};
for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
web_serial_slot_t *slot = &s_slots[i];
web_serial_performance_session_t *row = &out->sessions[i];
*row = slot->performance;
row->active = slot->state == WEB_SERIAL_SLOT_ACTIVE;
row->socket_fd = slot->socket_fd;
row->generation = slot->generation;
row->broker_client_id = slot->broker_client_id;
row->pending = slot->work_pending;
row->measured_pending = row->pending && gate && slot->performance_epoch == gate;
row->executing = row->measured_pending && row->executing;
row->pending_age_us = row->measured_pending && now >= slot->queued_us ?
(uint64_t)(now - slot->queued_us) : 0;
}
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
static esp_err_t identity_is_current(const user_principal_t *principal,
web_session_id_t id, bool *current)
@@ -377,6 +466,9 @@ static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd,
if (slot->state != WEB_SERIAL_SLOT_FREE) {
continue;
}
memset(&slot->performance, 0, sizeof(slot->performance));
slot->performance_epoch = 0;
slot->completion_waiting = false;
slot->generation = next_generation(slot->generation);
slot->state = WEB_SERIAL_SLOT_RESERVED;
slot->server = server;
@@ -902,6 +994,8 @@ static void finish_closing_slot_locked(web_serial_slot_t *slot)
static void web_serial_send_work(void *argument)
{
uint32_t entry_epoch = performance_gate();
int64_t entry_us = entry_epoch ? esp_timer_get_time() : 0;
web_serial_work_t *work = argument;
web_serial_slot_t *slot = work != NULL ? work->slot : NULL;
if (!slot_pointer_valid(slot)) {
@@ -928,6 +1022,14 @@ static void web_serial_send_work(void *argument)
generation = work->generation;
type = slot->tx_type;
length = slot->tx_length;
uint32_t sample_epoch = owned_work && entry_epoch &&
entry_epoch == performance_gate() && slot->performance_epoch == entry_epoch &&
type == HTTPD_WS_TYPE_BINARY ? entry_epoch : 0;
if (sample_epoch) {
performance_time(slot, &slot->performance.queue_wait, slot->queued_us, entry_us);
slot->performance.executing = valid;
if (!valid) performance_add(slot, &slot->performance.retired, 1);
}
if (owned_work && !valid) {
slot->work_pending = false;
slot->tx_length = 0U;
@@ -943,6 +1045,8 @@ static void web_serial_send_work(void *argument)
}
esp_err_t result = ESP_FAIL;
int64_t send_start = 0, send_end = 0;
bool send_called = false;
void *current_context = httpd_sess_get_ctx(server, socket_fd);
if (current_context == slot &&
httpd_ws_get_fd_info(server, socket_fd) ==
@@ -954,7 +1058,12 @@ static void web_serial_send_work(void *argument)
.payload = slot->tx_data,
.len = length,
};
result = httpd_ws_send_frame_async(server, socket_fd, &frame);
send_called = true;
if (sample_epoch && performance_gate() == sample_epoch) send_start = esp_timer_get_time();
result = type == HTTPD_WS_TYPE_BINARY
? web_httpd_ws_send_binary(server, socket_fd, slot, slot->tx_data, length)
: httpd_ws_send_frame_async(server, socket_fd, &frame);
if (sample_epoch && performance_gate() == sample_epoch) send_end = esp_timer_get_time();
if (result == ESP_OK) {
(void)httpd_sess_update_lru_counter(server, socket_fd);
}
@@ -963,6 +1072,19 @@ static void web_serial_send_work(void *argument)
taskENTER_CRITICAL(&s_lock);
if (slot->work_pending && slot->generation == generation &&
work == &slot->work) {
if (sample_epoch && performance_gate() == sample_epoch &&
slot->performance_epoch == sample_epoch) {
slot->performance.executing = false;
if (send_called) performance_time(slot, &slot->performance.send_call, send_start, send_end);
if (result == ESP_OK) {
performance_add(slot, &slot->performance.sent_frames, 1);
performance_add(slot, &slot->performance.sent_bytes, length);
slot->completion_epoch = sample_epoch;
slot->completed_us = send_end;
slot->completion_waiting = true;
slot->completion_attempted = false;
} else performance_add(slot, &slot->performance.send_errors, 1);
}
slot->work_pending = false;
slot->tx_length = 0U;
if (result == ESP_OK) {
@@ -986,6 +1108,8 @@ static void web_serial_send_work(void *argument)
static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation,
httpd_ws_type_t type, size_t length)
{
uint32_t epoch = performance_gate();
int64_t queued_us = epoch ? esp_timer_get_time() : 0;
httpd_handle_t server;
bool prepared = false;
@@ -994,6 +1118,13 @@ static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation,
slot->generation == generation && !slot->work_pending &&
!slot->close_requested && slot->server == s_server &&
length <= sizeof(slot->tx_data)) {
slot->performance_epoch = type == HTTPD_WS_TYPE_BINARY && epoch == performance_gate() ? epoch : 0;
if (slot->performance_epoch) {
slot->queued_us = queued_us;
slot->performance.executing = false;
performance_add(slot, &slot->performance.queued_frames, 1);
performance_add(slot, &slot->performance.queued_bytes, length);
}
slot->tx_type = type;
slot->tx_length = length;
slot->work.slot = slot;
@@ -1025,6 +1156,8 @@ static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation,
slot->tx_length = 0U;
slot->close_requested = true;
++s_counters.queue_failures;
if (epoch && performance_gate() == epoch && slot->performance_epoch == epoch)
performance_add(slot, &slot->performance.queue_errors, 1);
}
taskEXIT_CRITICAL(&s_lock);
notify_transport_task();
@@ -1168,9 +1301,29 @@ static bool prepare_writer_sync(web_serial_slot_t *slot, uint32_t generation,
static void drain_binary_output(web_serial_slot_t *slot, uint32_t generation,
session_broker_client_id_t client_id)
{
uint32_t epoch = performance_gate();
size_t received = 0U;
esp_err_t result = session_broker_read(client_id, slot->tx_data,
sizeof(slot->tx_data), &received);
int64_t drained_us = epoch && performance_gate() == epoch ? esp_timer_get_time() : 0;
if (epoch) {
taskENTER_CRITICAL(&s_lock);
if (performance_gate() == epoch && slot->completion_epoch == epoch &&
slot->generation == generation && slot->broker_client_id == client_id &&
slot->state == WEB_SERIAL_SLOT_ACTIVE && slot->completion_waiting) {
bool nonempty = result == ESP_OK && received > 0;
if (!slot->completion_attempted) {
performance_time(slot, &slot->performance.completion_attempt, slot->completed_us, drained_us);
if (nonempty) performance_time(slot, &slot->performance.completion_first_nonempty, slot->completed_us, drained_us);
}
slot->completion_attempted = true;
if (nonempty) {
performance_time(slot, &slot->performance.completion_nonempty, slot->completed_us, drained_us);
slot->completion_waiting = false;
}
}
taskEXIT_CRITICAL(&s_lock);
}
if (result == ESP_OK && received > 0U) {
(void)queue_slot_frame(slot, generation, HTTPD_WS_TYPE_BINARY, received);
} else if (result == ESP_ERR_NOT_FOUND) {
+38
View File
@@ -135,6 +135,44 @@ esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id);
esp_err_t web_serial_transport_get_snapshot(
web_serial_transport_snapshot_t *snapshot);
typedef struct {
uint64_t count, sum_us, max_us;
} web_serial_performance_timing_t;
typedef struct {
bool active, pending, measured_pending, executing, saturated;
int socket_fd;
uint32_t generation;
session_broker_client_id_t broker_client_id;
uint64_t pending_age_us;
uint64_t queued_frames, queued_bytes, queue_errors;
uint64_t sent_frames, sent_bytes, send_errors, retired;
web_serial_performance_timing_t queue_wait, send_call;
web_serial_performance_timing_t completion_attempt, completion_nonempty;
web_serial_performance_timing_t completion_first_nonempty;
} web_serial_performance_session_t;
typedef struct {
bool enabled, epoch_exhausted;
uint32_t epoch;
web_serial_performance_session_t sessions[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
} web_serial_performance_snapshot_t;
/* Binary TX only; no broker/HTTPD queries, heap scans, or sensitive identities.
* queued_* counts transport reservations, including reported queue failures;
* sent_* counts successful send-call returns. send_errors includes owner-context
* rejection; send_call timings count only actual API calls. queue_wait includes
* owned callbacks retired without sending. Pending age is since reservation-path
* entry, including an executing send; unavailable epochs are explicitly marked.
* All timing endpoints are local monotonic estimates, never peer acknowledgments.
* Disable freezes aggregates; enable resumes them. Every toggle/clear fences
* in-flight samples. Clear preserves enabled state. Epoch exhaustion fails closed.
* Nonempty completion intervals include idle; first_nonempty requires the first
* subsequent read to return data (not proof of backlog at send completion). */
esp_err_t web_serial_performance_enable(bool enabled);
esp_err_t web_serial_performance_clear(void);
esp_err_t web_serial_performance_snapshot(web_serial_performance_snapshot_t *out);
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
esp_err_t web_serial_transport_clear_counters(void);
+198 -15
View File
@@ -13,6 +13,7 @@
#include "esp_log.h"
#include "esp_netif_ip_addr.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "secure_random.h"
@@ -25,6 +26,11 @@
#include "web_serial_transport.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_broker_settings.h"
#include "web_ssh_settings.h"
#include "web_lifecycle_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -41,6 +47,8 @@ static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server;
static bool s_initialized;
static bool s_transitioning;
/* Firmware-lifetime lifecycle fence, independent of counters and handle reuse. */
static uint32_t s_generation = 1U;
static bool s_serial_transport_init_attempted;
static bool s_serial_transport_initialized;
static bool s_serial_transport_attached;
@@ -409,6 +417,52 @@ static const httpd_uri_t s_account_generate_password_uri = {
.handler = web_account_generate_password_handler,
};
static const httpd_uri_t s_network_uri = {
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
};
static const httpd_uri_t s_lifecycle_settings_uri = {
.uri = "/api/settings/lifecycle", .method = HTTP_GET, .handler = web_lifecycle_settings_handler,
};
static const httpd_uri_t s_lifecycle_operation_get_uri = {
.uri = "/api/settings/lifecycle-operation", .method = HTTP_GET, .handler = web_lifecycle_operation_handler,
};
static const httpd_uri_t s_lifecycle_operation_post_uri = {
.uri = "/api/settings/lifecycle-operation", .method = HTTP_POST, .handler = web_lifecycle_operation_handler,
};
static const httpd_uri_t s_ssh_settings_uri = {
.uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler,
};
static const httpd_uri_t s_ssh_operation_get_uri = {
.uri = "/api/settings/ssh-operation", .method = HTTP_GET, .handler = web_ssh_operation_handler,
};
static const httpd_uri_t s_ssh_operation_post_uri = {
.uri = "/api/settings/ssh-operation", .method = HTTP_POST, .handler = web_ssh_operation_handler,
};
static const httpd_uri_t s_broker_uri = {
.uri = "/api/settings/broker", .method = HTTP_GET, .handler = web_broker_settings_handler,
};
static const httpd_uri_t s_broker_operation_get_uri = {
.uri = "/api/settings/broker-operation", .method = HTTP_GET, .handler = web_broker_operation_handler,
};
static const httpd_uri_t s_broker_operation_post_uri = {
.uri = "/api/settings/broker-operation", .method = HTTP_POST, .handler = web_broker_operation_handler,
};
static const httpd_uri_t s_display_uri = {
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
};
static const httpd_uri_t s_display_operation_get_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_GET, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_display_operation_post_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_POST, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_network_operation_get_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_GET, .handler = web_network_operation_handler,
};
static const httpd_uri_t s_network_operation_post_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_POST, .handler = web_network_operation_handler,
};
static const httpd_uri_t s_root_uri = {
.uri = "/",
.method = HTTP_GET,
@@ -578,30 +632,27 @@ esp_err_t web_server_init(void)
s_serial_transport_error = serial_transport_error;
s_serial_transport_initialized = serial_transport_error == ESP_OK;
}
s_initialized = true;
if (s_last_error == ESP_ERR_INVALID_STATE) {
if (!s_initialized && s_last_error == ESP_ERR_INVALID_STATE) {
s_last_error = ESP_OK;
}
s_initialized = true;
xSemaphoreGive(s_server_mutex);
/* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */
return ESP_OK;
}
esp_err_t web_server_start(void)
static esp_err_t start_server(bool reserved)
{
esp_err_t error = web_server_init();
if (error != ESP_OK) {
return error;
}
esp_err_t error;
bool serial_transport_ready;
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server != NULL || s_transitioning) {
if (s_server != NULL || s_transitioning != reserved) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
s_transitioning = true;
if (s_generation != UINT32_MAX) ++s_generation;
serial_transport_ready = s_serial_transport_initialized;
xSemaphoreGive(s_server_mutex);
@@ -624,7 +675,7 @@ esp_err_t web_server_start(void)
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 10U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 25U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -684,6 +735,26 @@ esp_err_t web_server_start(void)
(void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET);
(void)web_httpd_register_optional(server, &s_account_generate_password_uri);
(void)web_httpd_register_optional(server, &s_account_keys_uri);
if (web_httpd_register_optional_get(server, &s_network_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_network_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_network_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_network_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_display_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_display_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_broker_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_ssh_settings_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_ssh_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_ssh_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_ssh_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_lifecycle_settings_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_lifecycle_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_lifecycle_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_lifecycle_operation_get_uri.uri, HTTP_GET);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
@@ -693,6 +764,7 @@ esp_err_t web_server_start(void)
if (cleanup_error == ESP_OK) cleanup_error = httpd_ssl_stop(server);
if (cleanup_error == ESP_OK) {
web_httpd_idle_stopped(server);
web_lifecycle_settings_stopped(server);
server = NULL;
} else {
/* Retain ownership so stop can retry and start cannot allocate a second server. */
@@ -718,14 +790,23 @@ esp_err_t web_server_start(void)
return error;
}
esp_err_t web_server_stop(void)
esp_err_t web_server_start(void)
{
esp_err_t error = web_server_init();
return error == ESP_OK ? start_server(false) : error;
}
static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool reserved)
{
if (s_server_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server == NULL || s_transitioning) {
if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE)
return ESP_ERR_TIMEOUT;
if (s_server == NULL || s_transitioning != reserved ||
(expected_generation && (expected_generation != s_generation ||
s_generation == UINT32_MAX || s_last_error != ESP_OK))) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
@@ -734,6 +815,7 @@ esp_err_t web_server_stop(void)
bool admin_transport_owned = s_admin_transport_owned;
esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true;
if (s_generation != UINT32_MAX) ++s_generation;
xSemaphoreGive(s_server_mutex);
web_cookie_auth_stop();
@@ -772,7 +854,10 @@ esp_err_t web_server_stop(void)
}
esp_err_t error = httpd_ssl_stop(server);
if (error == ESP_OK) web_httpd_idle_stopped(server);
if (error == ESP_OK) {
web_httpd_idle_stopped(server);
web_lifecycle_settings_stopped(server);
}
if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server);
if (error != ESP_OK && serial_transport_attached) {
/* Stay detached: old HTTPD work may still be reading static TX storage. */
@@ -780,7 +865,8 @@ esp_err_t web_server_stop(void)
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
/* Do not expose a stopped/unreserved gap to another lifecycle caller. */
s_transitioning = error == ESP_OK && restart;
s_last_error = error;
s_serial_transport_error = serial_transport_error;
s_serial_transport_attached = false;
@@ -790,9 +876,106 @@ esp_err_t web_server_stop(void)
++s_counters.stops;
}
xSemaphoreGive(s_server_mutex);
return error == ESP_OK && restart ? start_server(true) : error;
}
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
uint32_t expected_identity_generation, bool reset, bool *committed)
{
if (!committed || (!!expected_service_generation != !!expected_identity_generation) ||
(reset && expected_service_generation)) return ESP_ERR_INVALID_ARG;
*committed = false;
/* Conditional dispatcher admission must not wait in the legacy initializer. */
esp_err_t error = expected_service_generation
? (s_server_mutex ? ESP_OK : ESP_ERR_INVALID_STATE) : web_server_init();
if (error != ESP_OK) return error;
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_transitioning || (expected_service_generation &&
(!s_server || s_last_error != ESP_OK || s_generation == UINT32_MAX ||
expected_service_generation != s_generation))) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
bool running = s_server != NULL;
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
uint32_t token = 0;
error = web_security_reserve_identity(expected_identity_generation, reset, &token);
if (error == ESP_OK) {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_generation != UINT32_MAX) ++s_generation;
xSemaphoreGive(s_server_mutex);
error = web_security_replace_reserved(token);
}
if (error == ESP_OK) {
*committed = true;
if (running) error = stop_server(0, true, true);
else if (reset) error = start_server(true);
else {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
xSemaphoreGive(s_server_mutex);
}
} else {
/* No identity publication: leave HTTPD and its logins untouched. */
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
xSemaphoreGive(s_server_mutex);
}
web_security_release_identity(token);
return error;
}
esp_err_t web_server_stop(void)
{
return stop_server(0U, false, false);
}
esp_err_t web_server_stop_current(uint32_t expected_generation)
{
if (!expected_generation) return ESP_ERR_INVALID_ARG;
return stop_server(expected_generation, false, false);
}
esp_err_t web_server_restart_current(uint32_t expected_generation)
{
if (!expected_generation) return ESP_ERR_INVALID_ARG;
return stop_server(expected_generation, true, false);
}
esp_err_t web_server_reboot_current(uint32_t expected_generation)
{
if (!expected_generation) return ESP_ERR_INVALID_ARG;
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_server == NULL || s_transitioning || s_last_error != ESP_OK ||
s_generation == UINT32_MAX || expected_generation != s_generation) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
s_transitioning = true;
++s_generation;
xSemaphoreGive(s_server_mutex);
esp_restart();
return ESP_FAIL; /* Defensive only: reset normally never returns. */
}
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot)
{
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
snapshot->generation = s_generation;
snapshot->running = s_server != NULL;
snapshot->transitioning = s_transitioning;
snapshot->controllable = s_initialized && s_server != NULL && !s_transitioning &&
s_last_error == ESP_OK && s_generation != UINT32_MAX;
xSemaphoreGive(s_server_mutex);
return ESP_OK;
}
esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot)
{
if (snapshot == NULL) {
+33 -1
View File
@@ -39,7 +39,39 @@ typedef struct {
/* Initialize runtime state without requiring valid certificate material. */
esp_err_t web_server_init(void);
/* Start one TLS-only server on all active network interfaces. */
typedef struct {
uint32_t generation;
bool running;
bool transitioning;
bool controllable;
} web_server_management_snapshot_t;
/* Zero-wait, secret-free projection; controllable excludes failed cleanup and
* exhausted generations. No HTTPD work or owner wait is performed. */
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot);
/* Conditional lifecycle admission under the canonical server mutex. Call only
* off HTTPD, after caller-owned authorization and bounded ACK handoff. These
* APIs do not authenticate, acknowledge, cancel on revocation, or bound HTTPD
* shutdown time. Restart reserves the lifecycle through stop and start; a failed
* stop never starts another server. Stale/exhausted/unclean state rejects without
* side effects. Canonical stop/start below remain the recovery path. */
esp_err_t web_server_stop_current(uint32_t expected_generation);
esp_err_t web_server_restart_current(uint32_t expected_generation);
/* Reserves this HTTPS generation before canonical whole-device esp_restart().
* Admission cannot be cancelled; normally does not return. Same caller rules. */
esp_err_t web_server_reboot_current(uint32_t expected_generation);
/* Combined identity/service owner operation, off HTTPD only. Nonzero expected
* generations select healthy running conditional rotation; both zero select CLI.
* reset is CLI-only and starts a stopped service; ordinary rotation leaves it stopped.
* committed reports irreversible NVS publication even when stop/start later fails.
* Reservation covers generation checks, crypto/commit and canonical stop/start. */
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
uint32_t expected_identity_generation, bool reset, bool *committed);
/* Start one TLS-only server on all active network interfaces.
* Start/stop may wait for HTTPD; never call from its task or queued callbacks. */
esp_err_t web_server_start(void);
esp_err_t web_server_stop(void);
+271
View File
@@ -0,0 +1,271 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_ssh_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "ssh_transport.h"
#include "ssh_security.h"
#include "mbedtls/base64.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
enum { ROTATE = 3 };
static const char *const s_actions[] = {"start", "stop", "disconnect", "rotate"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
uint32_t generation, target, identity_generation;
unsigned action;
unsigned state;
} ssh_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static ssh_operation_t s_operation;
static uint32_t s_next_id;
/* Three fields for service actions; rotation additionally requires identity_generation.
* No escapes, duplicates, extra fields or coercion. */
static bool parse(const char *body, size_t length, ssh_operation_t *operation)
{
const char *keys[] = {"action", "generation", "target", "identity_generation"};
unsigned seen = 0;
size_t pos = 0;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 4; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 4; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 4 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned action = 0;
for (; action < 4; ++action)
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
if (action == 4) return false;
operation->action = action;
++pos;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
else if (key == 2) operation->target = number;
else operation->identity_generation = number;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length &&
(operation->action == ROTATE ? seen == 15 && operation->identity_generation &&
operation->identity_generation != UINT32_MAX : seen == 7) && operation->generation &&
operation->generation != UINT32_MAX &&
((operation->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U));
}
void web_ssh_settings_execute(uint32_t id)
{
ssh_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
bool committed = false;
error = operation.action == ROTATE
? ssh_transport_replace_identity(operation.generation, operation.identity_generation, false, &committed)
: ssh_transport_manage_current(operation.action, operation.target, operation.generation);
state = error == ESP_OK ? OK :
(operation.action == ROTATE) ? FAILED :
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_ssh_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
ssh_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_ssh_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_ssh_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.state = s_operation.state;
operation.action = s_operation.action;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_ssh_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
ssh_transport_management_snapshot_t snapshot;
error = ssh_transport_get_management_snapshot(&snapshot);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"ssh_unavailable\"}");
goto done;
}
ssh_security_identity_snapshot_t identity = {0};
unsigned char fingerprint[48] = {0};
size_t fingerprint_length = 0;
bool have_identity = ssh_security_get_identity_snapshot(&identity) == ESP_OK;
if (have_identity && mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &fingerprint_length,
identity.metadata.sha256_fingerprint, sizeof(identity.metadata.sha256_fingerprint)) != 0) {
error = ESP_FAIL;
goto done;
}
while (fingerprint_length && fingerprint[fingerprint_length - 1] == '=') --fingerprint_length;
fingerprint[fingerprint_length] = 0;
char response[768];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,"
"\"identity_generation\":%" PRIu32 ",\"algorithm\":\"%s\",\"fingerprint\":\"%s%s\",\"rotatable\":%s,\"sessions\":[",
snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false",
have_identity ? identity.metadata.generation : 0, SSH_SECURITY_KEY_TYPE,
have_identity ? "SHA256:" : "", fingerprint,
have_identity && !identity.busy && identity.metadata.generation != UINT32_MAX &&
!snapshot.transitioning && snapshot.generation != UINT32_MAX ? "true" : "false");
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
size_t used = (size_t)written;
unsigned count = 0;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *session = &snapshot.sessions[i];
if (!session->active) continue;
char name[USER_DATABASE_USERNAME_CAPACITY * 2 + 1];
static const char hex[] = "0123456789abcdef";
size_t n = 0;
for (; session->principal_valid && n < USER_DATABASE_USERNAME_CAPACITY && session->username[n]; ++n) {
unsigned byte = (unsigned char)session->username[n];
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
}
name[n * 2] = 0;
written = snprintf(response + used, sizeof(response) - used,
"%s{\"id\":%" PRIu32 ",\"state\":%u,\"route\":%u,\"name_hex\":\"%s\",\"closing\":%s}",
count++ ? "," : "", session->session_id, (unsigned)session->state,
(unsigned)session->route, name, session->close_requested ? "true" : "false");
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
used += (size_t)written;
}
written = snprintf(response + used, sizeof(response) - used, "]}");
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only SSH status and login-isolated ordinary controls. */
esp_err_t web_ssh_settings_handler(httpd_req_t *request);
esp_err_t web_ssh_operation_handler(httpd_req_t *request);
void web_ssh_settings_execute(uint32_t id);
+919 -38
View File
File diff suppressed because it is too large Load Diff
+133 -19
View File
@@ -18,6 +18,7 @@
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "mdns_service.h"
#include "nvs.h"
#define WIFI_MANAGER_QUEUE_LENGTH 16U
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
@@ -1414,36 +1415,147 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
return ESP_OK;
}
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
/* Caller holds s_mutex; publication and owner admission are one transaction. */
static esp_err_t apply_config_locked(const wifi_app_config_t *config)
{
esp_err_t error = wifi_config_validate(config);
if (error != ESP_OK) {
return error;
}
if (s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_shared();
if (error != ESP_OK) return error;
if (s_shared.snapshot.config_generation == UINT32_MAX) return ESP_ERR_INVALID_STATE;
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
if (restart_radio) {
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
if (!enqueue_message(&message)) {
unlock_shared();
return ESP_ERR_TIMEOUT;
}
if (!enqueue_message(&message)) return ESP_ERR_TIMEOUT;
}
s_shared.config = *config;
++s_shared.snapshot.config_generation;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
s_shared.snapshot.ap_policy = config->ap_policy;
++s_shared.snapshot.counters.applies;
return ESP_OK;
}
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
{
if (!config) return ESP_ERR_INVALID_ARG;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
esp_err_t error = apply_config_locked(config);
unlock_shared();
return error;
}
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings)
{
if (!settings) return ESP_ERR_INVALID_ARG;
memset(settings, 0, sizeof(*settings));
if (!s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
settings->runtime = s_shared.snapshot;
portENTER_CRITICAL(&s_drop_mux);
settings->runtime.counters.queue_drops = s_queue_drops;
portEXIT_CRITICAL(&s_drop_mux);
settings->enabled_at_boot = s_shared.config.enabled_at_boot;
settings->ap_policy = s_shared.config.ap_policy;
settings->ap_channel = s_shared.config.ap_channel;
settings->ap_ssid_len = s_shared.config.ap_ssid_len;
memcpy(settings->ap_ssid, s_shared.config.ap_ssid, sizeof(settings->ap_ssid));
settings->ap_password_configured = s_shared.config.ap_psk_len != 0;
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
const wifi_config_sta_profile_t *source = &s_shared.config.profiles[i];
wifi_manager_profile_settings_t *target = &settings->profiles[i];
target->enabled = source->enabled;
target->priority = source->priority;
target->security = source->security;
target->ssid_len = source->ssid_len;
memcpy(target->ssid, source->ssid, sizeof(target->ssid));
target->password_configured = source->psk_len != 0;
}
unlock_shared();
return ESP_OK;
}
static bool generation_matches(uint32_t generation)
{
return generation && generation == s_shared.snapshot.config_generation;
}
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch)
{
if (!patch || patch->profile < -1 || patch->profile >= (int)WIFI_CONFIG_STA_PROFILE_COUNT ||
!patch->fields || patch->ssid_len > WIFI_CONFIG_SSID_MAX_LEN ||
patch->password_len > WIFI_CONFIG_PSK_MAX_LEN) return ESP_ERR_INVALID_ARG;
uint32_t allowed = WIFI_PATCH_SSID | WIFI_PATCH_PASSWORD |
(patch->profile < 0 ? WIFI_PATCH_BOOT | WIFI_PATCH_POLICY | WIFI_PATCH_CHANNEL :
WIFI_PATCH_ENABLED | WIFI_PATCH_PRIORITY | WIFI_PATCH_SECURITY);
if (patch->fields & ~allowed) return ESP_ERR_INVALID_ARG;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
wifi_app_config_t candidate = s_shared.config;
uint8_t *ssid, *ssid_len, *password, *password_len;
if (patch->profile < 0) {
if (patch->fields & WIFI_PATCH_BOOT) candidate.enabled_at_boot = patch->enabled_at_boot;
if (patch->fields & WIFI_PATCH_POLICY) candidate.ap_policy = patch->ap_policy;
if (patch->fields & WIFI_PATCH_CHANNEL) candidate.ap_channel = patch->ap_channel;
ssid = candidate.ap_ssid; ssid_len = &candidate.ap_ssid_len;
password = candidate.ap_psk; password_len = &candidate.ap_psk_len;
} else {
wifi_config_sta_profile_t *profile = &candidate.profiles[(unsigned)patch->profile];
if (patch->fields & WIFI_PATCH_ENABLED) profile->enabled = patch->enabled;
if (patch->fields & WIFI_PATCH_PRIORITY) profile->priority = patch->priority;
if (patch->fields & WIFI_PATCH_SECURITY) profile->security = patch->security;
ssid = profile->ssid; ssid_len = &profile->ssid_len;
password = profile->psk; password_len = &profile->psk_len;
}
if (patch->fields & WIFI_PATCH_SSID) {
memset(ssid, 0, WIFI_CONFIG_SSID_MAX_LEN);
memcpy(ssid, patch->ssid, patch->ssid_len); *ssid_len = patch->ssid_len;
}
if (patch->fields & WIFI_PATCH_PASSWORD) {
wifi_config_secure_wipe(password, WIFI_CONFIG_PSK_MAX_LEN);
memcpy(password, patch->password, patch->password_len); *password_len = patch->password_len;
}
esp_err_t error = apply_config_locked(&candidate);
wifi_config_secure_wipe(&candidate, sizeof(candidate));
unlock_shared();
return error;
}
esp_err_t wifi_manager_save_current(uint32_t generation)
{
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
/* Hold the config lock through persistence, not HTTPD. Local controls cannot
* change the selected generation while its bytes are being committed. */
esp_err_t error = generation_matches(generation) ? wifi_config_save(&s_shared.config) : ESP_ERR_NOT_FOUND;
unlock_shared();
return error;
}
esp_err_t wifi_manager_load_current(uint32_t generation)
{
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
/* wifi_config_load intentionally generates fallback credentials. Browser
* load must instead read the same canonical blob without that fallback. */
wifi_app_config_t candidate = {0};
esp_err_t error = wifi_config_storage_init();
nvs_handle_t handle;
if (error == ESP_OK) {
error = nvs_open(WIFI_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
if (error == ESP_OK) {
size_t size = sizeof(candidate);
error = nvs_get_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, &candidate, &size);
nvs_close(handle);
if (error == ESP_OK && size != sizeof(candidate)) error = ESP_ERR_INVALID_SIZE;
}
}
if (error == ESP_OK) error = apply_config_locked(&candidate);
wifi_config_secure_wipe(&candidate, sizeof(candidate));
unlock_shared();
return error;
}
static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
int enabled_at_boot)
{
@@ -1453,6 +1565,11 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
manager_message_t message = {.type = type};
lock_shared();
if (enabled_at_boot >= 0 && s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot &&
s_shared.snapshot.config_generation == UINT32_MAX) {
unlock_shared();
return ESP_ERR_INVALID_STATE;
}
if (!enqueue_message(&message)) {
unlock_shared();
return ESP_ERR_TIMEOUT;
@@ -1462,9 +1579,6 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) {
s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot;
++s_shared.snapshot.config_generation;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
}
unlock_shared();
return ESP_OK;
+46
View File
@@ -89,6 +89,52 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config);
*/
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config);
/* Secret-free working projection, copied together with runtime under the mutex.
* Zero wait: ESP_ERR_TIMEOUT means no snapshot was obtained. Password presence
* is the only credential metadata, needed to stage/enable disabled profiles. */
typedef struct {
uint8_t enabled, priority;
wifi_config_security_t security;
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
bool password_configured;
} wifi_manager_profile_settings_t;
typedef struct {
wifi_manager_snapshot_t runtime;
uint8_t enabled_at_boot, ap_channel;
wifi_config_ap_policy_t ap_policy;
uint8_t ap_ssid_len, ap_ssid[WIFI_CONFIG_SSID_MAX_LEN];
bool ap_password_configured;
wifi_manager_profile_settings_t profiles[WIFI_CONFIG_STA_PROFILE_COUNT];
} wifi_manager_settings_t;
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings);
enum {
WIFI_PATCH_BOOT = 1U << 0, WIFI_PATCH_POLICY = 1U << 1,
WIFI_PATCH_CHANNEL = 1U << 2, WIFI_PATCH_ENABLED = 1U << 3,
WIFI_PATCH_PRIORITY = 1U << 4, WIFI_PATCH_SECURITY = 1U << 5,
WIFI_PATCH_SSID = 1U << 6, WIFI_PATCH_PASSWORD = 1U << 7,
};
/* profile=-1 selects AP/global fields; 0..3 selects a station profile.
* Absent bits preserve CURRENT bytes, never a stale caller's secret copy.
* PASSWORD with length zero clears only when canonical validation permits it.
* Caller owns and must wipe this transient input after every exit path. */
typedef struct {
uint32_t fields;
int8_t profile;
uint8_t enabled_at_boot, ap_channel, enabled, priority;
wifi_config_ap_policy_t ap_policy;
wifi_config_security_t security;
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
uint8_t password_len, password[WIFI_CONFIG_PSK_MAX_LEN];
} wifi_manager_patch_t;
/* Dispatcher-only conditional operations. A nonzero expected generation must
* match under the mutation mutex; ESP_ERR_NOT_FOUND denotes stale selection.
* No generation wrap/reuse. Queue failure leaves RAM untouched. */
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch);
esp_err_t wifi_manager_save_current(uint32_t generation);
/* Stored-only load: never generates or installs unknown default credentials. */
esp_err_t wifi_manager_load_current(uint32_t generation);
/* Lifecycle requests are asynchronous and serialized by the manager task. */
esp_err_t wifi_manager_start(void);
esp_err_t wifi_manager_stop(void);

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