From cdc9c7335af3707a163b1bea77c30e8edf195a8a Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 15 Sep 2026 22:12:57 +0200 Subject: [PATCH] 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. --- CMakeLists.txt | 1 + README.md | 6 +- cmake/security_overrides.cmake | 102 +++++++ docs/agent/architecture.md | 6 +- docs/agent/code-map.md | 10 +- docs/agent/current-state.md | 14 +- docs/agent/design-decisions.md | 12 + docs/command_reference.md | 2 + docs/roadmap.md | 8 +- docs/security_hardening.md | 62 +++- docs/security_library_review.md | 143 ++++++++++ sdkconfig.defaults | 6 + src/CMakeLists.txt | 2 + src/ssh_memory.c | 62 ++++ src/ssh_memory.h | 31 ++ src/ssh_protocol_policy.c | 37 +++ src/ssh_protocol_policy.h | 10 + src/ssh_transport.c | 30 +- src/web_cookie_auth.c | 55 ++-- tests/sdk_security_overrides/README.md | 134 +++++++++ tests/sdk_security_overrides/alloc.h | 68 +++++ tests/sdk_security_overrides/https.c | 159 +++++++++++ tests/sdk_security_overrides/run.py | 301 ++++++++++++++++++++ tests/sdk_security_overrides/scratch.c | 119 ++++++++ tests/sdk_security_overrides/tls.c | 105 +++++++ tests/ssh_memory/README.md | 59 ++++ tests/ssh_memory/run.py | 141 ++++++++++ tests/ssh_memory/test.c | 214 ++++++++++++++ tests/ssh_protocol_policy/README.md | 141 ++++++++++ tests/ssh_protocol_policy/apply.c | 92 ++++++ tests/ssh_protocol_policy/context.c | 182 ++++++++++++ tests/ssh_protocol_policy/run.py | 265 ++++++++++++++++++ tests/ssh_protocol_policy/support.h | 24 ++ tests/ssh_protocol_policy/vendor.c | 239 ++++++++++++++++ tests/web_cookie_auth/admission_test.c | 173 ++++++++++++ tests/web_cookie_auth/run.py | 4 +- tests/web_cookie_auth/test.c | 71 ++++- tests/wolfssh_auth_contract/README.md | 29 +- tests/wolfssh_auth_contract/contract.c | 144 +++++++++- tests/wolfssh_auth_contract/run.py | 49 +++- tools/security_overrides.py | 374 +++++++++++++++++++++++++ 41 files changed, 3597 insertions(+), 89 deletions(-) create mode 100644 cmake/security_overrides.cmake create mode 100644 docs/security_library_review.md create mode 100644 src/ssh_memory.c create mode 100644 src/ssh_memory.h create mode 100644 src/ssh_protocol_policy.c create mode 100644 src/ssh_protocol_policy.h create mode 100644 tests/sdk_security_overrides/README.md create mode 100644 tests/sdk_security_overrides/alloc.h create mode 100644 tests/sdk_security_overrides/https.c create mode 100644 tests/sdk_security_overrides/run.py create mode 100644 tests/sdk_security_overrides/scratch.c create mode 100644 tests/sdk_security_overrides/tls.c create mode 100644 tests/ssh_memory/README.md create mode 100644 tests/ssh_memory/run.py create mode 100644 tests/ssh_memory/test.c create mode 100644 tests/ssh_protocol_policy/README.md create mode 100644 tests/ssh_protocol_policy/apply.c create mode 100644 tests/ssh_protocol_policy/context.c create mode 100644 tests/ssh_protocol_policy/run.py create mode 100644 tests/ssh_protocol_policy/support.h create mode 100644 tests/ssh_protocol_policy/vendor.c create mode 100644 tests/web_cookie_auth/admission_test.c create mode 100644 tools/security_overrides.py diff --git a/CMakeLists.txt b/CMakeLists.txt index b94ecb3..e8f1f15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,3 +13,4 @@ add_compile_definitions( 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) diff --git a/README.md b/README.md index a8cf909..bbf708f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one ## Development status -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:** 8A–8C 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 and 9B SSH admission/credential handling have passed host/build checks. Hardware validation is deferred to Phase 9 as a whole. Production readiness is not yet established. See [security hardening](docs/security_hardening.md) for scope, operational profiles, and validation gates. +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:** 8A–8C 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. Hardware validation is deferred to Phase 9 as a whole. Production readiness is not yet established. See [security hardening](docs/security_hardening.md) for scope, operational profiles, and validation gates. ### Browser administration @@ -72,6 +72,8 @@ This removes saved serial configuration and all other flash contents. The firmwa pio run ``` +The build requires the reviewed ESP-IDF 5.5.0 and pinned component sources. 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: @@ -93,6 +95,8 @@ The HTTPS interface uses a device-specific self-signed certificate and a same-or 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 diff --git a/cmake/security_overrides.cmake b/cmake/security_overrides.cmake new file mode 100644 index 0000000..b41ca53 --- /dev/null +++ b/cmake/security_overrides.cmake @@ -0,0 +1,102 @@ +# 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}") + +# 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) + idf_component_get_property(_target "${component}" COMPONENT_LIB) + if(NOT TARGET "${_target}") + message(FATAL_ERROR "Security override: missing component target ${component}") + 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() + +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}") +endforeach() diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index c018785..bd4c4d7 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -104,9 +104,9 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li ### HTTPS, WebSocket, and web serial -`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails. +`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. -Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission). +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_httpd_adapter` is the sole private IDF 5.5.0 boundary for duplicate headers, admission-before-101, consumed-scratch wiping, staged optional URI registration, combined binary sends and owner-only idle sweeps. Re-audit its version guard on SDK upgrades. HTTPD debug logging must not expose headers/tickets. `web_diagnostics` independently observes public post-TLS callbacks using six metadata records and a default-disabled 32-event ring; it cannot see preaccept/in-progress/failed TLS. [Admission diagnostics](../web_admission_diagnostics.md). @@ -128,7 +128,7 @@ Typed SSH settings use the existing ID dispatcher and original-login result slot `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. 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. +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: diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 6ceda0f..7dcd487 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -17,6 +17,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the - 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 (Phase 9C) + +- 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 four target sources without modifying SDK/managed components: HTTPS cleanup/private-key release, HTTPD scratch lifetime/null first read, ESP-TLS server-only protocol list, and wolfSSH password bounds/payload wiping. Original notices and compile properties retained; source/hash/target ambiguity fails configuration. Never hand-edit generated copies or silently repin. +- 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. + ## Secure randomness **Responsibility:** provide the sole project-owned, mutex-serialized application DRBG, seeded before Wi-Fi/radio use. @@ -71,6 +77,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - 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`. @@ -111,7 +118,8 @@ Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtur **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_auth_policy.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}` +- 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 diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index b6716c4..f065dbe 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -2,6 +2,18 @@ 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 9C — library cleanup / protocol policy — 2026-09-15 + +- 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. Password12–64ASCII/PBKDF2SHA25650k/generated24of64 unchanged pending costmeasurement; P256selfsigned2025–2049/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. + ## Phase 9B — SSH admission / credential handling — 2026-09-15 - 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. @@ -10,7 +22,7 @@ Working memory, not an implementation timeline. Source is authoritative; begin w - 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. -- **Next:** complete remaining secret-lifetime and protocol review; inspect pinned library private-key/password-packet/buffer-growth/destruction before claiming comprehensive zeroization. Focused application audit found and fixed admin staging retention and hidden-input truncation; other checked key/candidate handoffs already wipe. TLS/SSH algorithm/certificate/header/password/KDF policy and web receive-before-throttle/challenge fairness remain review items. Dependency advisory/license review and lifecycle runbooks still planned, not performed. No need to await 9B hardware sign-off to continue. +- 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. ## Phase 9A — crash/debug baseline — 2026-09-15 diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index d00cb9a..a6e30aa 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -134,6 +134,18 @@ Only constraints supported by implementation or current project documentation be **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). +## Dependency corrections are reproducible build inputs, not local SDK edits + +**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). + +**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. + +## Retired library storage and protocol defaults have explicit policies + +**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. + +**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 **Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, mDNS-hostname, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset. diff --git a/docs/command_reference.md b/docs/command_reference.md index f6d8a7b..2dc37b5 100644 --- a/docs/command_reference.md +++ b/docs/command_reference.md @@ -157,6 +157,8 @@ 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). diff --git a/docs/roadmap.md b/docs/roadmap.md index 34d346d..e742e70 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,7 +39,7 @@ These constraints apply across all phases: | 6 | Authenticated SSH serial transport | **Complete** | | 7 | Local display and button interface | **Complete** | | 8 | Role-based users and administrative access | **Complete (8D.22 accepted 2026-09-13)** | -| 9 | Security and production hardening | **In progress (9A/9B implemented; combined phase validation deferred)** | +| 9 | Security and production hardening | **In progress (9A–9C 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** | @@ -209,14 +209,14 @@ Phase 8 is complete for its accepted scope. Phase 9 has started at the user's re ### Phase 9 — Security and production hardening -**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-upgrade changes are part of 9A/9B; 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. +**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 9A–9C; 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. Staged work: 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. **Next — Complete secret-lifetime and protocol review.** Audit remaining application/library allocations, private-key and packet-buffer destruction; review TLS/SSH algorithms, certificates, browser headers, password/KDF policy and remaining web-admission exposure. 9B's focused fixes are not full zeroization or protocol certification. -4. **Later — Maintenance and lifecycle.** Review dependency advisories and licenses without assuming pinned versions are permanently safe; document provisioning, rotation, factory reset, backup, recovery and decommissioning. OTA signing trust needs an independent policy without secure boot (Phase 10). +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. **Next — 9D maintenance and lifecycle.** Review current dependency advisories and licenses without assuming pinned versions or local corrections are permanently safe; document provisioning, rotation, factory reset, backup, recovery and decommissioning. Upgrades require re-auditing the source overrides. OTA signing trust needs an independent policy without secure boot (Phase 10). 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. diff --git a/docs/security_hardening.md b/docs/security_hardening.md index a0e028e..3c01853 100644 --- a/docs/security_hardening.md +++ b/docs/security_hardening.md @@ -1,6 +1,6 @@ # Security hardening — Phase 9 -**Status: in progress.** Phase 8 is complete at the accepted 8D.22 scope. **9A crash/debug policy and 9B SSH admission/credential handling** 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. This document records policy and procedures, not unrun passes or production certification. +**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. This document records policy and procedures, not unrun passes or production certification. ## Scope and threat model @@ -65,6 +65,43 @@ The transport now wipes consumed admin RX bytes, positively accepted admin TX by 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.0, applies exact-once edits, and generates four corrected sources under the build directory. `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.0 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 advertisement | `ssh-ed25519`, `ecdsa-sha2-nistp256`; database authorization remains authoritative | + +`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. Mainstream-client interoperability and rekey remain target gates, not host-test claims. 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. + ## Operational profiles These are handling and validation profiles of the **same supported build baseline**, not separate PlatformIO environments or selectable security overrides. @@ -81,7 +118,7 @@ Raw flash, RAM and dumps can contain Wi-Fi passwords, private keys, password ver ## Validation gates -### Host and build — passed 2026-09-15 (9A and 9B) +### Host and build — passed 2026-09-15 (9A–9C) From the repository root: @@ -93,15 +130,19 @@ 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 ``` -Latest 9B `pio run` passed with **94,340 B linked RAM / 1,829,925 B flash**, +144 B RAM / +1,360 B flash against 9A. This is linked size, not measured runtime headroom. All four new focused host suites passed, including 35 pinned-vendor parser/control-flow cases and actual compiler-feature preprocessing. The crash-policy generated-header matrix passed all 18 cases. Related SSH management/security/runtime, console boundary/accounts/lifecycle/policy, cookie-auth SSH/accounts and browser-admin transport/tickets regressions passed (11 commands). Independent review and `git diff --check` passed. 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. +Latest 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 final phase test session; do not stop implementation for a separate 9A/9B sign-off. +Retain these checks for the final phase test session; do not stop implementation for a separate 9A/9B/9C sign-off. #### Crash and recovery @@ -120,11 +161,18 @@ Retain these checks for the final phase test session; do not stop implementation 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. 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. Host canary 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. + ## Staged next work -- **Next: complete the secret-lifetime and protocol review.** 9B fixes confirmed admin-buffer retention and hidden-input truncation, but does not complete the library-allocation audit. Inspect pinned private-key import/destruction, password-packet storage and buffer growth/free paths in mbedTLS/wolfSSL/wolfSSH before claiming full zeroization. Review negotiated TLS/SSH algorithms, certificate trust/validity, browser security headers and password/KDF policy without casually changing shared crypto or persisted identities. -- **Web admission review remains separate.** `src/web_cookie_auth.c` still limits credential verifications to five per 60-second fixed global window, shared across clients. This is not a sliding window or persistent account lockout; auth lifecycle restart resets it. Challenge starvation/global-budget starvation and the receive-before-throttle path remain review items, not changes delivered by 9B. -- **Later: maintenance and lifecycle.** Review ESP-IDF/wolfSSL/wolfSSH advisories and dependency licenses, then plan any upgrades separately. Complete provisioning, key rotation, backup, factory reset, recovery and decommissioning runbooks without claiming physical-extraction resistance or secure erasure. +- **Next — 9D maintenance and lifecycle.** Review current ESP-IDF/wolfSSL/wolfSSH advisories and third-party licenses, then plan any upgrades and source re-audits separately. Complete provisioning, key rotation, backup, factory reset, recovery and decommissioning runbooks without claiming physical-extraction resistance or secure erasure. No external advisory or complete license review has been performed by 9C. +- **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. diff --git a/docs/security_library_review.md b/docs/security_library_review.md new file mode 100644 index 0000000..d6fc7b2 --- /dev/null +++ b/docs/security_library_review.md @@ -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: **12–64 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. diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 1dd658a..1461539 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -86,3 +86,9 @@ CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y # 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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1f4e00d..9ebbf28 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -26,6 +26,8 @@ idf_component_register( "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" diff --git a/src/ssh_memory.c b/src/ssh_memory.c new file mode 100644 index 0000000..dc8ae24 --- /dev/null +++ b/src/ssh_memory.c @@ -0,0 +1,62 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "ssh_memory.h" + +#include + +#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, 0) +#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.0 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; +} diff --git a/src/ssh_memory.h b/src/ssh_memory.h new file mode 100644 index 0000000..990a951 --- /dev/null +++ b/src/ssh_memory.h @@ -0,0 +1,31 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once + +#include + +#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 diff --git a/src/ssh_protocol_policy.c b/src/ssh_protocol_policy.c new file mode 100644 index 0000000..9c75a99 --- /dev/null +++ b/src/ssh_protocol_policy.c @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "ssh_protocol_policy.h" + +#include +#include +#include + +#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; +} diff --git a/src/ssh_protocol_policy.h b/src/ssh_protocol_policy.h new file mode 100644 index 0000000..8d70794 --- /dev/null +++ b/src/ssh_protocol_policy.h @@ -0,0 +1,10 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once + +#include + +/* 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); diff --git a/src/ssh_transport.c b/src/ssh_transport.c index 41b34de..1fd5e58 100644 --- a/src/ssh_transport.c +++ b/src/ssh_transport.c @@ -10,7 +10,7 @@ #include #include "admin_ssh_console.h" -#include "esp_heap_caps.h" + #include "esp_log.h" #include "esp_system.h" #include "esp_timer.h" @@ -25,6 +25,8 @@ #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 #include @@ -323,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) { @@ -741,6 +725,12 @@ 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); @@ -1540,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; } diff --git a/src/web_cookie_auth.c b/src/web_cookie_auth.c index 42e4986..8c768b8 100644 --- a/src/web_cookie_auth.c +++ b/src/web_cookie_auth.c @@ -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) { diff --git a/tests/sdk_security_overrides/README.md b/tests/sdk_security_overrides/README.md new file mode 100644 index 0000000..5bd884a --- /dev/null +++ b/tests/sdk_security_overrides/README.md @@ -0,0 +1,134 @@ +# Pinned SDK security overrides + +The root `CMakeLists.txt` includes `cmake/security_overrides.cmake` **after** +`project()`. No component/vendor file is edited, and no global crypto feature or +client ciphersuite setting is changed. + +## Build contract + +`tools/security_overrides.py` requires the installed ESP-IDF version header to +identify **5.5.0**, and checks each complete original source against its reviewed +SHA256. Every text substitution must match **exactly once**. The entire input +plan is validated before any output is written. A changed SDK, missing source, +ambiguous edit, duplicate source, or missing/ambiguous component target fails +configuration; there is no unpatched fallback or automatic hash repinning. + +Derived **full files**, retaining the original copyright/license notices, live +only at `/security_overrides//`. +The manifest and derived files are atomically replaced only when their bytes +change. Output paths cannot escape the binary tree or alias SDK/source files. +CMake tracks the generator, version header, originals, and derived sources for +reconfiguration. The included CMake file is itself an ordinary CMake input. +Requirements: Python 3.9+ and CMake 3.18+ (directory-scoped source properties). + +CMake replaces the exact original entry in the existing component's `SOURCES`; +it does not add a second definition or replace the component target. Target +compile settings remain intact. Source compile flags/options/definitions, +per-configuration definitions, source includes and object dependencies are +copied in the target's owning directory. The original C file's directory is +prepended to that source's include search path, preserving quoted local headers. +Source generator expressions are rejected rather than guessed through. + +### Current corrections + +- `esp_https_server:src/https_server.c`: delete TLS if post-handshake transport + allocation fails; destroy the complete secure context if HTTPD start fails; + wipe exactly `serverkey_bytes` before releasing the raw private-key copy. + Failed start restores the original open callback and clears stale transport + context/destructor pointers. Failed stop retains live ownership. +- `esp_http_server:src/httpd_parse.c`: allocate/copy/wipe/free scratch resize, + retaining old storage on failure; wipe current scratch on final cleanup. + Initial reads avoid null-pointer subtraction and preserve a null parser + position until a callback sets it; existing positions relocate with scratch. + Existing shrink/grow behavior and bounds remain. Resizing briefly owns old + plus new allocations; no persistent maximum-size buffer, socket, task or + limit increase. Pending/unread bytes are not erased or drained by the patch. +- `esp-tls:esp_tls_mbedtls.c`: **server-local** static-lifetime allowlist of + `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` and + `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384`, terminated by zero, configured after + server defaults and before setup. Both version limits are TLS 1.2; + renegotiation is disabled (or absent at compile time). Required TLS/ECDHE/ + ECDSA/AES/GCM/SHA features are compile-guarded. IDF dynamic buffers are rejected + because their destructor bypasses the audited upstream record-buffer wipe. + `set_client_config()` and the shared handle/setup path are unchanged. +- `wolfssl__wolfssh:src/internal.c`: the fourth override pins wolfSSH 1.4.20's + original source SHA256 to + `81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9`. + `GetSize()` bounds password and new-password fields before authentication; + malformed parsing cannot reach the auth callback. The checked packet suffix + is wiped before failure responses, preserving the caller's prefix. Pending + asynchronous authentication retains the payload for retry; this is **not** + an async secret-lifetime/wipe guarantee. Generated parser/control-flow tests + live in `tests/wolfssh_auth_contract/`. + +Clients that cannot negotiate this server profile will no longer connect. +Live interoperability and resource/latency testing remain hardware gates. +These corrections do not claim comprehensive zeroization of every TLS/library +copy, compiler spill, accelerator register, browser buffer or allocator region. + +## Parent extension point + +Add an `Entry` to `tools/security_overrides.py:ENTRIES` with: + +- unique `name`; +- exact IDF `component` name (used by `idf_component_get_property`); +- `root="idf"` for installed IDF sources, or `root="project"` for project/vendor + sources; +- exact relative `source`, full reviewed `sha256`, and a tuple of `Edit(old,new)` + exact-once substitutions. + +`render_entry()` validates/patches an entry; `generate()` accepts an explicit +entry tuple as well as the default registry. The manifest maps each entry to +its component, original and derived source. CMake's +`sak_security_replace_source(component original generated)` handles replacement +without backend assumptions. The current registry uses this for three IDF +sources and the project-managed wolfSSH source described above. Update the +corresponding library-specific feature/behavior tests when extending the registry. Multiple +sources in the same real component are supported by the replacement function. + +Do not change a source hash merely to make a new SDK configure. Re-audit ownership, +cleanup, feature resolution and patches against that source revision first. + +## Validation + +```sh +python3 tests/sdk_security_overrides/run.py +python3 tests/sdk_security_overrides/run.py --build-dir .pio/build/esp32-s3-devkitc-1-n16r8 +``` + +Optional `--idf-path` selects an existing installed SDK. Tests need host `cc`, +CMake and Ninja; they install nothing, use temporary directories under `.pio/`, and never edit +the selected SDK. The second command also checks the existing real firmware +Ninja registration: exactly one compilation of each derived source, no original +compilation, and exact generated bytes. It does **not** run a firmware build. + +Coverage: + +- Generator full-source hashes, version, missing/duplicate/ambiguous inputs, + exact edit counts, validation-before-output, unchanged-byte/mtime idempotence, + unsafe output rejection, and preserved upstream notices. +- Extracted **patched actual functions**, not reimplemented cleanup logic: + HTTPS allocation failure matrix; handshake failure; post-handshake allocation + failure; HTTPD start failure; normal close/stop; failed stop preserving ownership. + The unmodified installed `httpd_stop()` is extracted and separately pinned. + Allocator doubles assert key bytes are zero **before** free, with trailing + canaries to reject over-wiping, and detect leaks/double frees. +- Actual patched scratch helper/read/cleanup functions: grow, shrink, no-change, + null-initial first-read success/failure, nullable parser-position preservation, + resize failure preserving the old pointer, receive errors/timeouts, size bounds, final wipe and + original right-aligned pending-byte behavior. Installed pending/unrecv functions + are separately pinned and executed. This is not a full HTTP parser fuzz test. +- Actual server/client configuration functions with crypto/config doubles: + allowlist order/terminator/static lifetime, defaults failure, PKI failure, + version limits, renegotiation enabled/compiled-out variants, untouched default + and caller-provided client suites. Every required feature is individually + removed in compile-failure tests; dynamic-buffer enablement also fails. +- The actual CMake include under fake IDF target discovery, including missing and + duplicate sources/targets. A separate real host compile tests the project-root + extension, child-directory relative `SOURCES`, quoted and source-specific + includes, source/target flags and per-config source definitions. Changing that + fixture's original file makes the next ordinary build reconfigure and reject + its hash instead of compiling stale derived code. + +No tests here perform real TLS handshakes, network/device operations, allocation +failure on the target, or whole-Phase-9 hardware acceptance. diff --git a/tests/sdk_security_overrides/alloc.h b/tests/sdk_security_overrides/alloc.h new file mode 100644 index 0000000..88bff2f --- /dev/null +++ b/tests/sdk_security_overrides/alloc.h @@ -0,0 +1,68 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include +#include +#include +#include +#include +#include + +typedef struct { void *p; size_t n; bool wipe; } allocation; +static allocation allocations[64]; +static unsigned live, calls, fail_at, wiped_frees; +static size_t secret_size; +static bool all_secret; +static void *test_alloc(size_t n, bool clear) +{ + if (++calls == fail_at) return NULL; + void *p = clear ? calloc(1, n + 16) : malloc(n + 16); + assert(p); + if (!clear) memset(p, 0xa5, n); + memset((unsigned char *)p + n, 0x7b, 16); + for (unsigned i = 0; i < 64; ++i) if (!allocations[i].p) { + allocations[i] = (allocation){p, n, all_secret || n == secret_size}; + ++live; + return p; + } + abort(); +} +static void test_free(void *p) +{ + if (!p) return; + for (unsigned i = 0; i < 64; ++i) if (allocations[i].p == p) { + for (size_t j = 0; j < 16; ++j) + assert(((unsigned char *)p)[allocations[i].n + j] == 0x7b); + if (allocations[i].wipe) { + for (size_t j = 0; j < allocations[i].n; ++j) + assert(((unsigned char *)p)[j] == 0); + ++wiped_frees; + } + memset(p, 0xdd, allocations[i].n); + allocations[i].p = NULL; + --live; + free(p); + return; + } + assert(!"double free or unowned allocation"); +} +static void mark_secret(void *p) +{ + for (unsigned i = 0; i < 64; ++i) if (allocations[i].p == p) { + allocations[i].wipe = true; + return; + } + abort(); +} +#define malloc(n) test_alloc((n), false) +#define calloc(n, s) test_alloc((n) * (s), true) +#define free(p) test_free(p) +#define ESP_LOGE(...) ((void)0) +#define ESP_LOGD(...) ((void)0) +#define ESP_LOGI(...) ((void)0) +#define ESP_LOGW(...) ((void)0) +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_NO_MEM -2 +#define ESP_ERR_INVALID_ARG -3 +#define ESP_ERR_INVALID_STATE -4 +#define ESP_ERR_NOT_SUPPORTED -5 +typedef int esp_err_t; diff --git a/tests/sdk_security_overrides/https.c b/tests/sdk_security_overrides/https.c new file mode 100644 index 0000000..e7c98d8 --- /dev/null +++ b/tests/sdk_security_overrides/https.c @@ -0,0 +1,159 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "alloc.h" +typedef void *httpd_handle_t; +typedef int (*httpd_open_func_t)(httpd_handle_t, int); +typedef void esp_https_server_user_cb(void *); +typedef struct { unsigned char secret[37]; } esp_tls_t; +typedef void *esp_tls_error_handle_t; +typedef struct { int last_error, esp_tls_error_code, esp_tls_flags; } esp_https_server_last_error_t; +typedef struct { int user_cb_state; esp_tls_t *tls; } esp_https_server_user_cb_arg_t; +typedef struct { + const unsigned char *cacert_buf, *servercert_buf, *serverkey_buf; + unsigned cacert_bytes, servercert_bytes, serverkey_bytes; + void *userdata; const char **alpn_protos; + unsigned tls_handshake_timeout_ms; bool use_secure_element; +} esp_tls_cfg_server_t; +typedef struct { + void *global_transport_ctx, *global_user_ctx; + void (*global_transport_ctx_free_fn)(void *), (*global_user_ctx_free_fn)(void *); + httpd_open_func_t open_fn; + int server_port, ctrl_port; +} httpd_config_t; +struct httpd_ssl_config { + httpd_config_t httpd; + bool session_tickets, use_secure_element, use_ecdsa_peripheral; + const unsigned char *cacert_pem, *servercert, *prvtkey_pem; + unsigned cacert_len, servercert_len, prvtkey_len, tls_handshake_timeout_ms; + void *ssl_userdata; const char **alpn_protos; + esp_https_server_user_cb *user_cb; + int transport_mode, port_secure, port_insecure; +}; +struct httpd_data { + httpd_config_t config; int msg_fd; + struct { int status; } hd_td; + void *transport; + void (*close_fn)(void *); +}; +struct httpd_ctrl_data { int hc_msg; }; +#define HTTPD_SSL_TRANSPORT_SECURE 1 +#define HTTPD_SSL_USER_CB_SESS_CLOSE 2 +#define HTTPD_SSL_USER_CB_SESS_CREATE 3 +#define HTTPS_SERVER_EVENT_ERROR 4 +#define HTTPS_SERVER_EVENT_ON_CONNECTED 5 +#define HTTPS_SERVER_EVENT_DISCONNECTED 6 +#define HTTPS_SERVER_EVENT_START 7 +#define HTTPS_SERVER_EVENT_STOP 8 +#define HTTP_SERVER_EVENT_STOP 9 +#define HTTPD_CTRL_SHUTDOWN 10 +#define THREAD_STOPPED 11 +static bool start_failure, stop_failure, handshake_failure; +static unsigned deletes, creates, closes; +static struct httpd_data *active; +static void http_dispatch_event_to_event_loop(int id, const void *v, size_t n) {} +static void esp_http_server_dispatch_event(int id, const void *v, size_t n) {} +static int esp_tls_cfg_server_session_tickets_init(esp_tls_cfg_server_t *cfg) { return 0; } +static void esp_tls_cfg_server_session_tickets_free(esp_tls_cfg_server_t *cfg) {} +static esp_tls_t *esp_tls_init(void) { + esp_tls_t *tls = calloc(1, sizeof(*tls)); + if (tls) { memset(tls, 0xb6, sizeof(*tls)); mark_secret(tls); } + return tls; +} +static int esp_tls_server_session_create(esp_tls_cfg_server_t *cfg, int fd, esp_tls_t *tls) { + return handshake_failure ? -1 : 0; +} +static void esp_tls_server_session_delete(esp_tls_t *tls) { + assert(tls); ++deletes; memset(tls, 0, sizeof(*tls)); free(tls); +} +static int esp_tls_get_error_handle(esp_tls_t *tls, esp_tls_error_handle_t *e) { return -1; } +static int esp_tls_get_and_clear_last_error(esp_tls_error_handle_t e, int *a, int *b) { return 0; } +static void *httpd_get_global_transport_ctx(httpd_handle_t h) { return ((struct httpd_data *)h)->config.global_transport_ctx; } +static void httpd_sess_set_transport_ctx(httpd_handle_t h, int fd, void *ctx, void (*fn)(void *)) { + struct httpd_data *hd = h; assert(!hd->transport); hd->transport = ctx; hd->close_fn = fn; +} +static int httpd_ssl_send(void) { return 0; } +static int httpd_ssl_recv(void) { return 0; } +static int httpd_ssl_pending(void) { return 0; } +static void httpd_sess_set_send_override(httpd_handle_t h, int fd, int (*fn)(void)) {} +static void httpd_sess_set_recv_override(httpd_handle_t h, int fd, int (*fn)(void)) {} +static void httpd_sess_set_pending_override(httpd_handle_t h, int fd, int (*fn)(void)) {} +static int httpd_start(httpd_handle_t *h, httpd_config_t *cfg) { + if (start_failure) return ESP_FAIL; + struct httpd_data *hd = calloc(1, sizeof(*hd)); + if (!hd) return ESP_ERR_NO_MEM; + hd->config = *cfg; *h = hd; active = hd; return ESP_OK; +} +static int cs_send_to_ctrl_sock(int fd, int port, void *msg, size_t n) { return stop_failure ? -1 : 0; } +static void httpd_os_thread_sleep(int ms) { + if (active->transport) { + active->close_fn(active->transport); + active->transport = NULL; + } + active->hd_td.status = THREAD_STOPPED; +} +static void httpd_delete(struct httpd_data *hd) { assert(!hd->transport); free(hd); active = NULL; } +static void user_callback(void *arg) { + esp_https_server_user_cb_arg_t *a = arg; + if (a->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE) ++creates; + if (a->user_cb_state == HTTPD_SSL_USER_CB_SESS_CLOSE) ++closes; +} +/* SDK_FUNCTIONS */ +static struct httpd_ssl_config config(void) { + static unsigned char ca[13], cert[19], key[23]; + memset(ca, 1, sizeof(ca)); memset(cert, 2, sizeof(cert)); memset(key, 3, sizeof(key)); + return (struct httpd_ssl_config){.transport_mode=HTTPD_SSL_TRANSPORT_SECURE, + .cacert_pem=ca, .cacert_len=sizeof(ca), .servercert=cert, .servercert_len=sizeof(cert), + .prvtkey_pem=key, .prvtkey_len=sizeof(key), .user_cb=user_callback}; +} +int main(void) { + secret_size = 23; + for (unsigned fail = 1; fail <= 6; ++fail) { + struct httpd_ssl_config cfg = config(); httpd_handle_t h = NULL; + calls = 0; fail_at = fail; + assert(httpd_ssl_start(&h, &cfg) != ESP_OK); + assert(!h && live == 0); + } + fail_at = 0; + struct httpd_ssl_config cfg = config(); httpd_handle_t h = NULL; + start_failure = true; + unsigned wipes = wiped_frees; + assert(httpd_ssl_start(&h, &cfg) != ESP_OK && live == 0); + assert(wiped_frees == wipes + 1); + assert(!cfg.httpd.global_transport_ctx && !cfg.httpd.global_transport_ctx_free_fn); + assert(!cfg.httpd.open_fn); /* no stale HTTPS wrapper on a retry */ + start_failure = false; + assert(httpd_ssl_start(&h, &cfg) == ESP_OK); + assert(httpd_ssl_stop(h) == ESP_OK && live == 0); + h = NULL; + for (unsigned missing = 0; missing < 2; ++missing) { + cfg = config(); + if (missing) cfg.prvtkey_pem = NULL; else cfg.servercert = NULL; + assert(httpd_ssl_start(&h, &cfg) != ESP_OK && live == 0); + } + cfg = config(); + assert(httpd_ssl_start(&h, &cfg) == ESP_OK); + unsigned baseline = live; + for (unsigned fail = 1; fail <= 2; ++fail) { + calls = 0; fail_at = fail; unsigned before = deletes; + assert(httpd_ssl_open(h, 42) == ESP_ERR_NO_MEM); + assert(live == baseline && !active->transport); + assert(deletes == before + (fail == 2)); + } + fail_at = 0; handshake_failure = true; + unsigned before = deletes; + assert(httpd_ssl_open(h, 42) != ESP_OK && live == baseline); + assert(deletes == before + 1); + handshake_failure = false; + assert(httpd_ssl_open(h, 42) == ESP_OK && creates == 1); + void *retained = active->transport; unsigned retained_live = live; + stop_failure = true; before = deletes; wipes = wiped_frees; + assert(httpd_ssl_stop(h) != ESP_OK); + assert(active->transport == retained && live == retained_live); + assert(deletes == before && wiped_frees == wipes && closes == 0); + stop_failure = false; + assert(httpd_ssl_stop(h) == ESP_OK && live == 0 && closes == 1); + assert(deletes == before + 1 && wiped_frees == wipes + 2); + assert(httpd_ssl_stop(NULL) == ESP_ERR_INVALID_ARG); + cfg = config(); cfg.transport_mode = 0; start_failure = true; + assert(httpd_ssl_start(&h, &cfg) != ESP_OK && live == 0); + puts("HTTPS allocation/handshake/start/stop ownership and wipe matrix PASS"); +} diff --git a/tests/sdk_security_overrides/run.py b/tests/sdk_security_overrides/run.py new file mode 100644 index 0000000..634cb6b --- /dev/null +++ b/tests/sdk_security_overrides/run.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Read installed SDK sources; compile extracted patched functions with host doubles. + +No dependency writes or network. --build-dir additionally verifies a real IDF +build's Ninja source registration; it does not run a firmware build. +""" +# SPDX-License-Identifier: GPL-3.0-only +from __future__ import annotations +import argparse +from dataclasses import replace +import hashlib +import importlib.util +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location("security_overrides", ROOT / "tools/security_overrides.py") +sdk = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = sdk +SPEC.loader.exec_module(sdk) +TLS_ENTRY = next(e for e in sdk.ENTRIES if e.name == "esp_tls_mbedtls") + + +def source_path(entry, idf, project=ROOT): + return {"idf": idf, "project": project}[entry.root] / entry.source + + +AUXILIARY = { + "components/esp_http_server/src/httpd_main.c": "a16ef65069dda13889c67b922f25eb566573983d6c24f01c089a902d5fd26149", + "components/esp_http_server/src/httpd_txrx.c": "7659ad52c32f29b9a08208dc8b22d023edf274047835ed58107d82a47ccce00e", +} +FEATURES = ["MBEDTLS_SSL_PROTO_TLS1_2", "MBEDTLS_SSL_SRV_C", + "MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED", "MBEDTLS_ECDH_C", + "MBEDTLS_ECDSA_C", "MBEDTLS_AES_C", "MBEDTLS_GCM_C", + "MBEDTLS_SHA256_C", "MBEDTLS_SHA384_C"] + + +def run(command, *, ok=True, cwd=None): + env = dict(os.environ, CCACHE_DISABLE="1", PYTHONDONTWRITEBYTECODE="1", + TMPDIR=str(ROOT / ".pio")) + result = subprocess.run([str(x) for x in command], cwd=cwd, env=env, + capture_output=True, text=True, timeout=60) + if (result.returncode == 0) != ok: + raise AssertionError(f"command: {command}\n{result.stdout}\n{result.stderr}") + return result.stdout + result.stderr + + +def extract(text, name): + matches = list(re.finditer(r"^[A-Za-z_][\w* \t]*\b" + re.escape(name) + r"\([^;]*?\)\s*\{", text, re.M)) + assert len(matches) == 1, (name, len(matches)) + start = matches[0].start() + brace = matches[0].end() - 1 + depth = 0 + tokens = re.finditer(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', text[brace:], re.S) + for token in tokens: + if token.group() == "{": depth += 1 + elif token.group() == "}": + depth -= 1 + if depth == 0: return text[start:brace + token.end()] + "\n" + raise AssertionError(name) + + +def typedef(text, name): + match = re.search(r"typedef struct " + name + r"(?:_t)? \{.*?\} " + name + r"_t;", text, re.S) + assert match, name + return match.group() + "\n" + + +def expect_error(function, phrase): + try: + function() + except (sdk.OverrideError, OSError) as error: + assert phrase in str(error), str(error) + else: + raise AssertionError("expected rejection: " + phrase) + + +def generator_tests(idf, work): + binary = work / "generated" + manifest = sdk.generate(idf, ROOT, binary) + before = {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in binary.rglob("*") if p.is_file()} + assert sdk.generate(idf, ROOT, binary) == manifest + assert before == {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in before} + for entry in sdk.ENTRIES: + original = source_path(entry, idf).read_bytes() + derived = (binary / "security_overrides" / entry.name / Path(entry.source).name).read_bytes() + assert derived.startswith(original[:original.index(b"*/") + 2]) + assert derived != original + expect_error(lambda: sdk.apply_edits("x", (sdk.Edit("missing", "z"),)), "got 0") + expect_error(lambda: sdk.apply_edits("xx", (sdk.Edit("x", "z"),)), "got 2") + expect_error(lambda: sdk.generate(idf, ROOT, binary, ()), "absent") + expect_error(lambda: sdk.generate(idf, ROOT, binary, (sdk.ENTRIES[0],) * 2), "duplicate") + expect_error(lambda: sdk.generate(idf, ROOT, binary, + (sdk.ENTRIES[0], replace(sdk.ENTRIES[0], name="alias"))), "ambiguous") + expect_error(lambda: sdk.generate(idf, ROOT, idf / "forbidden"), "separate") + fake = work / "sdk" + version = Path("components/esp_common/include/esp_idf_version.h") + for rel in [version] + [Path(e.source) for e in sdk.ENTRIES if e.root == "idf"]: + target = fake / rel; target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(idf / rel, target) + last = fake / TLS_ENTRY.source + last.write_bytes(last.read_bytes() + b"\n/* changed dependency */\n") + failed = work / "failed" + expect_error(lambda: sdk.generate(fake, ROOT, failed), "SHA256 mismatch") + assert not failed.exists(), "must validate all inputs before output" + # A failed regeneration must not silently update even the first derived file. + expect_error(lambda: sdk.generate(fake, ROOT, binary), "SHA256 mismatch") + assert before == {p: (p.read_bytes(), p.stat().st_mtime_ns) for p in before} + shutil.copyfile(idf / TLS_ENTRY.source, last) + (fake / version).write_text((fake / version).read_text().replace("VERSION_PATCH 0", "VERSION_PATCH 1")) + expect_error(lambda: sdk.generate(fake, ROOT, failed), "5.5.0") + shutil.copyfile(idf / version, fake / version) + last.unlink() + expect_error(lambda: sdk.generate(fake, ROOT, failed), "No such file") + escaped = work / "escaped_output"; escaped.mkdir() + (escaped / "security_overrides").symlink_to(fake, target_is_directory=True) + expect_error(lambda: sdk.generate(idf, ROOT, escaped), "output escapes") + print("Generator exact hashes/version/absent/ambiguous/atomic-plan/idempotence/path safety PASS") + return binary + + +def extracted_tests(idf, binary, work): + texts = {e.name: (binary / "security_overrides" / e.name / Path(e.source).name).read_text() for e in sdk.ENTRIES} + aux = {} + for rel, expected in AUXILIARY.items(): + raw = (idf / rel).read_bytes() + assert hashlib.sha256(raw).hexdigest() == expected, rel + aux[Path(rel).name] = raw.decode() + https = texts["https_server"] + functions = typedef(https, "httpd_ssl_ctx") + typedef(https, "httpd_ssl_transport_ctx") + functions += extract(aux["httpd_main.c"], "httpd_stop") + for name in ("security_override_wipe", "httpd_ssl_close", "httpd_ssl_open", + "free_secure_context", "create_secure_context", "httpd_ssl_start", "httpd_ssl_stop"): + functions += extract(https, name) + source = (HERE / "https.c").read_text().replace("/* SDK_FUNCTIONS */", functions) + compile_run("https", source, work) + scratch = texts["httpd_parse"] + functions = "".join(extract(aux["httpd_txrx.c"], name) for name in ("httpd_recv_pending", "httpd_unrecv")) + functions += "".join(extract(scratch, name) for name in ("security_override_wipe", "security_override_resize_scratch", "read_block", "httpd_req_cleanup")) + compile_run("scratch", (HERE / "scratch.c").read_text().replace("/* SDK_FUNCTIONS */", functions), work) + tls = texts["esp_tls_mbedtls"] + original = (idf / TLS_ENTRY.source).read_text() + assert extract(tls, "set_client_config") == extract(original, "set_client_config") + assert extract(tls, "esp_create_mbedtls_handle") == extract(original, "esp_create_mbedtls_handle") + guards = tls[tls.index("/* The server profile"):tls.index('static const char *TAG = "esp-tls-mbedtls";')] + functions = extract(tls, "set_server_config") + extract(tls, "set_client_config") + source = (HERE / "tls.c").read_text().replace("/* SDK_FUNCTIONS */", functions) + source = source.replace("/* SDK_PKI */", typedef(tls, "esp_tls_pki")).replace("/* TLS_GUARDS */", guards) + defines = ["-D" + f for f in FEATURES] + compile_run("tls", source, work, defines + ["-DMBEDTLS_SSL_RENEGOTIATION", "-DCONFIG_MBEDTLS_SSL_RENEGOTIATION"]) + compile_run("tls_no_renegotiation", source, work, defines) + # Compile actual injected guards independently of the behavioral doubles. + guard_file = work / "guards.c"; guard_file.write_text(guards) + for feature in FEATURES: + run(["cc", "-E", "-x", "c", *["-D" + f for f in FEATURES if f != feature], guard_file], ok=False) + run(["cc", "-E", "-x", "c", *defines, "-DCONFIG_MBEDTLS_DYNAMIC_BUFFER", guard_file], ok=False) + print("TLS feature guard matrix (each required feature + dynamic buffer rejection) PASS") + + +def compile_run(name, source, work, flags=()): + c = work / (name + ".c"); exe = work / name + c.write_text("/* Extracted SDK sections retain their upstream Apache-2.0 license. */\n" + source) + run(["cc", "-std=gnu11", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-Wno-unused-function", "-Wno-unused-variable", *flags, "-I", HERE, c, "-o", exe]) + print(run([exe]).strip()) + + +def cmake_fixture_tests(idf, work): + # Use real component inputs with mock IDF target discovery. No SDK compilation. + fixture = work / "cmake_fixture"; fixture.mkdir() + lines = ["cmake_minimum_required(VERSION 3.18)", "project(security_fixture C)", + f'set(TEST_IDF "{idf}")', + 'function(idf_build_get_property out property)', + ' set(${out} "${TEST_IDF}" PARENT_SCOPE)', 'endfunction()', + 'function(idf_component_get_property out component property)', + ' set(${out} "test_${component}" PARENT_SCOPE)', 'endfunction()'] + for e in sdk.ENTRIES: + if e.root == "project": + copied = fixture / e.source + copied.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source_path(e, idf), copied) + lines += [f'add_library(test_{e.component} STATIC "{source_path(e, idf, fixture)}")'] + lines += ['if(TEST_MISSING)', f'set_property(TARGET test_{sdk.ENTRIES[0].component} PROPERTY SOURCES missing.c)', 'endif()', + 'if(TEST_AMBIGUOUS)', f'set_property(TARGET test_{sdk.ENTRIES[0].component} APPEND PROPERTY SOURCES "{source_path(sdk.ENTRIES[0], idf, fixture)}")', 'endif()', + 'if(TEST_TARGET_MISSING)', 'function(idf_component_get_property out component property)', + 'set(${out} nonexistent PARENT_SCOPE)', 'endfunction()', 'endif()'] + for e in sdk.ENTRIES: + lines += [f'set_source_files_properties("{source_path(e, idf, fixture)}" PROPERTIES COMPILE_FLAGS "-DSOURCE_FLAG" COMPILE_DEFINITIONS "SOURCE_DEFINE" COMPILE_OPTIONS "-fno-common")'] + lines += [f'include("{ROOT / "cmake/security_overrides.cmake"}")'] + for e in sdk.ENTRIES: + lines += [f'file(GENERATE OUTPUT "${{CMAKE_BINARY_DIR}}/{e.name}.sources" CONTENT "$")', + f'get_property(flags SOURCE "${{SAK_SECURITY_{e.name}_GENERATED}}" PROPERTY COMPILE_FLAGS)', + 'if(NOT flags STREQUAL "-DSOURCE_FLAG")', 'message(FATAL_ERROR "lost compile flags")', 'endif()', + f'get_property(inc SOURCE "${{SAK_SECURITY_{e.name}_GENERATED}}" PROPERTY INCLUDE_DIRECTORIES)', + f'if(NOT inc MATCHES "{source_path(e, idf, fixture).parent}")', 'message(FATAL_ERROR "lost original quoted include directory")', 'endif()'] + (fixture / "CMakeLists.txt").write_text("\n".join(lines) + "\n") + build = work / "cmake_good" + run(["cmake", "-G", "Ninja", "-S", fixture, "-B", build]) + for e in sdk.ENTRIES: + source = (build / (e.name + ".sources")).read_text() + assert source == str(build / "security_overrides" / e.name / Path(e.source).name) + ninja = (build / "build.ninja").read_text() + for path in [ROOT / "tools/security_overrides.py", idf / "components/esp_common/include/esp_idf_version.h"] + [source_path(e, idf, fixture) for e in sdk.ENTRIES]: + assert str(path) in next(line for line in ninja.splitlines() if ": RERUN_CMAKE" in line), path + for flag, phrase in (("TEST_MISSING", "found 0"), ("TEST_AMBIGUOUS", "found 2"), ("TEST_TARGET_MISSING", "missing component target")): + output = run(["cmake", "-G", "Ninja", "-S", fixture, "-B", work / flag, "-D" + flag + "=ON"], ok=False) + assert phrase in output, output + print("CMake actual include: exact target replacement/properties/reconfigure/fail-closed matrix PASS") + + +def extension_fixture_tests(idf, work): + # Prove the extension API, relative SOURCES in a child directory, real quoted + # includes, target/source flags, and automatic fail-closed reconfiguration. + fixture = work / "extension" + for directory in ("cmake", "tools", "component/src", "component/include"): + (fixture / directory).mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / "cmake/security_overrides.cmake", fixture / "cmake/security_overrides.cmake") + c = fixture / "component/src/example.c" + c.write_text('#include "local.h"\n#include "extra.h"\n' + '#if !defined(SOURCE_FLAG) || !defined(SOURCE_DEFINE) || !defined(SOURCE_OPTION) || !defined(TARGET_DEFINE)\n' + '#error "compile properties were lost"\n#endif\n' + 'int example(void) { return LOCAL + EXTRA + 1; }\n') + original = c.read_bytes(); digest = hashlib.sha256(original).hexdigest() + (c.parent / "local.h").write_text("#define LOCAL 10\n") + (fixture / "component/include/extra.h").write_text("#define EXTRA 20\n") + (fixture / "component/CMakeLists.txt").write_text('add_library(test_extension STATIC src/example.c)\n' + 'target_compile_definitions(test_extension PRIVATE TARGET_DEFINE)\n' + 'set_source_files_properties(src/example.c PROPERTIES COMPILE_FLAGS "-DSOURCE_FLAG" ' + 'COMPILE_OPTIONS "-DSOURCE_OPTION" COMPILE_DEFINITIONS "SOURCE_DEFINE" ' + 'COMPILE_DEFINITIONS_DEBUG "CONFIG_DEFINE" INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/include")\n') + (fixture / "main.c").write_text('int example(void); int main(void) { return example() != 32; }\n') + wrapper = ('import sys\nfrom pathlib import Path\nsys.dont_write_bytecode = True\n' + f'sys.path.insert(0, {str(ROOT / "tools")!r})\nimport security_overrides as sdk\n' + 'import argparse\np=argparse.ArgumentParser()\n' + '[p.add_argument(a, type=Path, required=True) for a in ("--idf-path", "--project-dir", "--binary-dir")]\n' + 'a=p.parse_args()\n' + f'e=sdk.Entry("extension", "extension", "project", "component/src/example.c", {digest!r}, ' + '(sdk.Edit("LOCAL + EXTRA + 1", "LOCAL + EXTRA + 2"),))\n' + 'sdk.generate(a.idf_path, a.project_dir, a.binary_dir, (e,))\n') + (fixture / "tools/security_overrides.py").write_text(wrapper) + (fixture / "CMakeLists.txt").write_text('cmake_minimum_required(VERSION 3.18)\nproject(extension C)\n' + f'set(TEST_IDF "{idf}")\n' + 'function(idf_build_get_property out property)\nset(${out} "${TEST_IDF}" PARENT_SCOPE)\nendfunction()\n' + 'function(idf_component_get_property out component property)\nset(${out} "test_${component}" PARENT_SCOPE)\nendfunction()\n' + 'add_subdirectory(component)\ninclude(cmake/security_overrides.cmake)\n' + 'get_property(config_def SOURCE "${SAK_SECURITY_extension_GENERATED}" DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/component" PROPERTY COMPILE_DEFINITIONS_DEBUG)\n' + 'if(NOT config_def STREQUAL "CONFIG_DEFINE")\nmessage(FATAL_ERROR "lost per-config source definitions")\nendif()\n' + 'add_executable(check main.c)\ntarget_link_libraries(check PRIVATE test_extension)\n') + build = work / "extension_build" + run(["cmake", "-G", "Ninja", "-S", fixture, "-B", build]) + run(["cmake", "--build", build]); run([build / "check"]) + assert c.read_bytes() == original + generated = build / "security_overrides/extension/example.c" + stamp = generated.stat().st_mtime_ns + run(["cmake", "--build", build]); assert generated.stat().st_mtime_ns == stamp + c.write_bytes(original + b"\n/* upstream changed */\n") + output = run(["cmake", "--build", build], ok=False) + assert "SHA256 mismatch" in output, output + assert generated.stat().st_mtime_ns == stamp + print("Extension mapping + child relative source/includes/flags real compile + automatic mismatch rejection PASS") + + +def build_registration(build, idf): + ninja = (build / "build.ninja").read_text() + compile_lines = [line for line in ninja.splitlines() if ": C_COMPILER" in line] + for e in sdk.ENTRIES: + generated = build / "security_overrides" / e.name / Path(e.source).name + matches = [line for line in compile_lines if str(generated) in line] + assert len(matches) == 1, (e.name, matches) + assert not any(str(source_path(e, idf)) in line for line in compile_lines), e.name + assert generated.read_bytes() == sdk.render_entry(e, {"idf": idf, "project": ROOT})[1] + print("Real IDF Ninja registration: each generated source once, originals absent, bytes verified PASS") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--idf-path", type=Path, default=Path.home() / ".platformio/packages/framework-espidf") + parser.add_argument("--build-dir", type=Path) + args = parser.parse_args() + idf = args.idf_path.resolve() + sdk.verify_version(idf) + (ROOT / ".pio").mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="sdk-security-", dir=ROOT / ".pio") as tmp: + work = Path(tmp) + binary = generator_tests(idf, work) + extracted_tests(idf, binary, work) + cmake_fixture_tests(idf, work) + extension_fixture_tests(idf, work) + if args.build_dir: build_registration(args.build_dir.resolve(), idf) + print("SDK security overrides: all requested host checks PASS (not live TLS/hardware)") + + +if __name__ == "__main__": + main() diff --git a/tests/sdk_security_overrides/scratch.c b/tests/sdk_security_overrides/scratch.c new file mode 100644 index 0000000..4ca4955 --- /dev/null +++ b/tests/sdk_security_overrides/scratch.c @@ -0,0 +1,119 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include +#include "alloc.h" +#define MIN(a,b) ((a) < (b) ? (a) : (b)) +#define HTTPD_SOCK_ERR_TIMEOUT -10 +#define HTTPD_SOCK_ERR_FAIL -11 +#define HTTPD_408_REQ_TIMEOUT 408 +struct sock_db { + char pending_data[128]; size_t pending_len; + void *ctx; void (*free_ctx)(void *); bool ignore_sess_ctx_changes; +}; +struct httpd_req_aux { + struct sock_db *sd; + char *scratch; size_t scratch_cur_size, scratch_size_limit, remaining_len; +}; +typedef struct httpd_req { + struct httpd_req_aux *aux; void *sess_ctx, *handle, *user_ctx; + void (*free_ctx)(void *); bool ignore_sess_ctx_changes; +} httpd_req_t; +typedef struct { void *data; } http_parser; +typedef struct { struct { char *at; } last; } parser_data_t; +static int receive_result = 1, receive_calls; +static int httpd_req_handle_err(httpd_req_t *r, int err) { return ESP_FAIL; } +static void httpd_sess_free_ctx(void **ctx, void (*fn)(void *)) { assert(!*ctx); } +static int httpd_recv_with_opt(httpd_req_t *r, char *buf, size_t n, bool halt_after_pending); +/* SDK_FUNCTIONS */ +static int httpd_recv_with_opt(httpd_req_t *r, char *buf, size_t n, bool halt_after_pending) { + ++receive_calls; + assert(halt_after_pending); + if (r->aux->sd->pending_len) return (int)httpd_recv_pending(r, buf, n); + if (receive_result <= 0) return receive_result; + memset(buf, 'x', n); return (int)n; +} +static void cleanup(httpd_req_t *r, struct httpd_req_aux *ra, struct sock_db *sd) { + r->aux = ra; ra->sd = sd; + httpd_req_cleanup(r); + assert(!ra->scratch && !ra->scratch_cur_size && !r->aux && !live); +} +int main(void) { + all_secret = true; + struct sock_db sd = {0}; + struct httpd_req_aux ra = {.sd=&sd, .scratch_size_limit=64, .remaining_len=37}; + httpd_req_t r = {.aux=&ra}; + parser_data_t data = {0}; http_parser parser = {.data=&data}; + /* Equivalent pointer/size initialization to parse_init/init_req_aux: + * the first read must allocate its own scratch, with no parser position. */ + assert(!data.last.at && !ra.scratch && !ra.scratch_cur_size); + unsigned initial_wipes = wiped_frees; + int initial_reads = receive_calls; + fail_at = calls + 1; + assert(read_block(&r, &parser, 0, 8) == 0); + assert(!data.last.at && !ra.scratch && !ra.scratch_cur_size && !live); + assert(receive_calls == initial_reads && wiped_frees == initial_wipes); + cleanup(&r, &ra, &sd); + assert(wiped_frees == initial_wipes); + fail_at = 0; + r.aux = &ra; ra.sd = &sd; ra.scratch_size_limit = 64; + assert(read_block(&r, &parser, 0, 8) == 8); + assert(ra.scratch && ra.scratch_cur_size == 8 && !data.last.at); + assert(receive_calls == initial_reads + 1 && wiped_frees == initial_wipes); + assert(!memcmp(ra.scratch, "xxxxxxxx", 8)); + /* A fragmented request can need another read before the URL callback. */ + char *initial = ra.scratch; + fail_at = calls + 1; + assert(read_block(&r, &parser, 8, 8) == 0); + assert(ra.scratch == initial && ra.scratch_cur_size == 8 && !data.last.at); + assert(receive_calls == initial_reads + 1 && wiped_frees == initial_wipes); + fail_at = 0; + assert(read_block(&r, &parser, 8, 8) == 8); + assert(ra.scratch != initial && ra.scratch_cur_size == 16 && !data.last.at); + assert(!memcmp(ra.scratch, "xxxxxxxxxxxxxxxx", 16)); + assert(wiped_frees == initial_wipes + 1); + cleanup(&r, &ra, &sd); + assert(wiped_frees == initial_wipes + 2); + + r.aux = &ra; ra.sd = &sd; ra.scratch_size_limit = 64; + assert(read_block(&r, &parser, 0, 16) == 16); + assert(!data.last.at); + memcpy(ra.scratch, "Cookie: secret!!", 16); + data.last.at = ra.scratch + 7; + char *old = ra.scratch; unsigned before = wiped_frees; + assert(read_block(&r, &parser, 16, 8) == 8); + assert(ra.scratch != old && ra.scratch_cur_size == 24); + assert(!memcmp(ra.scratch, "Cookie: secret!!", 16)); + assert(data.last.at == ra.scratch + 7 && wiped_frees == before + 1); + old = ra.scratch; unsigned saved_calls = calls; + assert(security_override_resize_scratch(&ra, 24) && ra.scratch == old && calls == saved_calls); + fail_at = calls + 1; + int reads = receive_calls; + assert(read_block(&r, &parser, 24, 8) == 0); + assert(ra.scratch == old && ra.scratch_cur_size == 24 && receive_calls == reads); + assert(data.last.at == old + 7 && !memcmp(old, "Cookie: secret!!", 16)); + assert(ra.remaining_len == 37); + fail_at = 0; + assert(httpd_unrecv(&r, "NEXT-REQUEST", 12) == 12); + assert(read_block(&r, &parser, 4, 4) == 4); /* actual shrink + pending RX */ + assert(ra.scratch_cur_size == 8 && !memcmp(ra.scratch, "CookNEXT", 8)); + assert(sd.pending_len == 8 && !memcmp(sd.pending_data + 120, "-REQUEST", 8)); + assert(ra.remaining_len == 37); + char pending[128]; memcpy(pending, sd.pending_data, sizeof(pending)); + cleanup(&r, &ra, &sd); + assert(sd.pending_len == 8 && !memcmp(pending, sd.pending_data, sizeof(pending))); + r.aux = &ra; ra.sd = &sd; + char out[12] = {0}; assert(httpd_recv_pending(&r, out, 8) == 8 && !memcmp(out, "-REQUEST", 8)); + ra.scratch_size_limit = 64; + fail_at = calls + 1; + assert(!security_override_resize_scratch(&ra, 8) && !ra.scratch); + cleanup(&r, &ra, &sd); fail_at = 0; + for (int result = 0; result >= -2; --result) { + r.aux = &ra; ra.sd = &sd; ra.scratch_size_limit = 64; + assert(security_override_resize_scratch(&ra, 8)); data.last.at = ra.scratch; + receive_result = result == -2 ? HTTPD_SOCK_ERR_TIMEOUT : result; + assert(read_block(&r, &parser, 0, 8) == HTTPD_SOCK_ERR_FAIL); + int before_reads = receive_calls; + assert(read_block(&r, &parser, 64, 1) == 0 && before_reads == receive_calls); + cleanup(&r, &ra, &sd); + } + puts("HTTPD null-initial read/grow/shrink/failure/final wipe/bounds/pending-unread matrix PASS"); +} diff --git a/tests/sdk_security_overrides/tls.c b/tests/sdk_security_overrides/tls.c new file mode 100644 index 0000000..69f7e22 --- /dev/null +++ b/tests/sdk_security_overrides/tls.c @@ -0,0 +1,105 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "alloc.h" +#define MBEDTLS_SSL_IS_SERVER 1 +#define MBEDTLS_SSL_IS_CLIENT 0 +#define MBEDTLS_SSL_TRANSPORT_STREAM 0 +#define MBEDTLS_SSL_PRESET_DEFAULT 0 +#define MBEDTLS_SSL_VERIFY_NONE 0 +#define MBEDTLS_SSL_VERIFY_REQUIRED 2 +#define MBEDTLS_SSL_VERSION_TLS1_2 0x303 +#define MBEDTLS_SSL_RENEGOTIATION_DISABLED 0 +#define MBEDTLS_SSL_RENEGOTIATION_ENABLED 1 +#define MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 0xc02b +#define MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 0xc02c +#define ESP_ERR_MBEDTLS_SSL_CONFIG_DEFAULTS_FAILED -11 +#define ESP_ERR_MBEDTLS_SSL_SET_HOSTNAME_FAILED -12 +#define ESP_ERR_MBEDTLS_SSL_SETUP_FAILED -13 +#define ESP_INT_EVENT_TRACKER_CAPTURE(...) ((void)0) +/* TLS_GUARDS */ +typedef int mbedtls_x509_crt; +typedef int mbedtls_pk_context; +typedef struct { + const int *suites; + int endpoint, min, max, reneg, policy_calls; + void *userdata; +} mbedtls_ssl_config; +typedef struct { + mbedtls_ssl_config conf; + int ssl, servercert, serverkey, clientcert, clientkey, error_handle; + void *cacert_ptr; +} esp_tls_t; +typedef struct { + void *userdata; const char **alpn_protos; + bool use_secure_element, use_ecdsa_peripheral; + const unsigned char *cacert_buf, *servercert_buf, *serverkey_buf, *serverkey_password; + unsigned cacert_bytes, servercert_bytes, serverkey_bytes, serverkey_password_len; +} esp_tls_cfg_server_t; +typedef struct { + bool skip_common_name, use_global_ca_store, use_secure_element, use_ecdsa_peripheral; + const char *common_name; const char **alpn_protos; + void *crt_bundle_attach, *ds_data; + const unsigned char *cacert_buf, *clientcert_buf, *clientkey_buf, *clientkey_password; + const unsigned char *clientcert_pem_buf, *clientkey_pem_buf; + unsigned cacert_bytes, clientcert_bytes, clientkey_bytes, clientkey_password_len; + const int *ciphersuites_list; +} esp_tls_cfg_t; +/* SDK_PKI */ +static int defaults_fail, pki_fail; +static const int default_suites[] = {123, 456, 0}; +static int mbedtls_ssl_config_defaults(mbedtls_ssl_config *c, int endpoint, int transport, int preset) { + if (defaults_fail) return -1; + *c = (mbedtls_ssl_config){.suites=default_suites, .endpoint=endpoint, + .min=11, .max=22, .reneg=1}; + return 0; +} +static void mbedtls_ssl_conf_ciphersuites(mbedtls_ssl_config *c, const int *list) { c->suites=list; ++c->policy_calls; } +static void mbedtls_ssl_conf_min_tls_version(mbedtls_ssl_config *c, int v) { c->min=v; } +static void mbedtls_ssl_conf_max_tls_version(mbedtls_ssl_config *c, int v) { c->max=v; } +static void mbedtls_ssl_conf_renegotiation(mbedtls_ssl_config *c, int v) { c->reneg=v; } +static void mbedtls_ssl_conf_set_user_data_p(mbedtls_ssl_config *c, void *p) { c->userdata=p; } +static void mbedtls_ssl_conf_authmode(mbedtls_ssl_config *c, int mode) {} +static void mbedtls_ssl_conf_ca_chain(mbedtls_ssl_config *c, void *p, void *q) {} +static int mbedtls_ssl_set_hostname(void *ssl, const char *host) { return 0; } +static void mbedtls_print_error_msg(int e) {} +static int set_ca_cert(esp_tls_t *tls, const unsigned char *cert, size_t n) { return 0; } +static int set_global_ca_store(esp_tls_t *tls) { return 0; } +static void check_policy(mbedtls_ssl_config *c) { + assert(c->endpoint == MBEDTLS_SSL_IS_SERVER && c->policy_calls == 1); + assert(c->suites[0] == 0xc02b && c->suites[1] == 0xc02c && c->suites[2] == 0); + assert(c->min == 0x303 && c->max == 0x303); +#ifdef MBEDTLS_SSL_RENEGOTIATION + assert(c->reneg == 0); +#endif +} +static int set_pki_context(esp_tls_t *tls, esp_tls_pki_t *pki) { + if (tls->conf.endpoint == MBEDTLS_SSL_IS_SERVER) check_policy(&tls->conf); + return pki_fail ? -1 : 0; +} +/* SDK_FUNCTIONS */ +int main(void) { + static const unsigned char cert[] = {1}, key[] = {2}; + esp_tls_cfg_server_t cfg = {.servercert_buf=cert, .serverkey_buf=key, .userdata=&cfg}; + esp_tls_t server = {0}, second = {0}, client = {0}; + defaults_fail = 1; + assert(set_server_config(&cfg, &server) == ESP_ERR_MBEDTLS_SSL_CONFIG_DEFAULTS_FAILED); + assert(!server.conf.policy_calls && !server.conf.suites); + defaults_fail = 0; + assert(set_server_config(&cfg, &server) == 0); check_policy(&server.conf); + assert(server.conf.userdata == &cfg); + assert(set_server_config(&cfg, &second) == 0); check_policy(&second.conf); + assert(server.conf.suites == second.conf.suites); /* retained static lifetime */ + esp_tls_cfg_t ccfg = {.skip_common_name=true, .use_global_ca_store=true}; + assert(set_client_config("host", 4, &ccfg, &client) == 0); + assert(client.conf.suites == default_suites && !client.conf.policy_calls); + assert(client.conf.min == 11 && client.conf.max == 22 && client.conf.reneg == 1); + static const int custom[] = {999, 0}; ccfg.ciphersuites_list = custom; + assert(set_client_config("host", 4, &ccfg, &client) == 0); + assert(client.conf.suites == custom && client.conf.policy_calls == 1); + check_policy(&server.conf); + pki_fail = 1; + assert(set_server_config(&cfg, &second) != 0); check_policy(&second.conf); + pki_fail = 0; cfg.serverkey_buf = NULL; + assert(set_server_config(&cfg, &second) == ESP_ERR_INVALID_STATE); + assert(!live); + puts("TLS server-only allowlist/version/renegotiation/config-failure/client isolation PASS"); +} diff --git a/tests/ssh_memory/README.md b/tests/ssh_memory/README.md new file mode 100644 index 0000000..98b1502 --- /dev/null +++ b/tests/ssh_memory/README.md @@ -0,0 +1,59 @@ +# SSH memory hook tests + +Run from the repository root: + +```sh +CCACHE_DISABLE=1 python3 tests/ssh_memory/run.py +``` + +Optionally verify the installed, audited SDK source contract too (no downloads): + +```sh +CCACHE_DISABLE=1 python3 tests/ssh_memory/run.py --idf-path /home/mscholz/.platformio/packages/framework-espidf +``` + +The runner compiles the actual `src/ssh_memory.c` and extracts the actual volatile +`secure_wipe()` body from `src/secure_random.c`. Only SDK headers and heap calls +are doubled. Temporary build files stay outside the repository. `CC` and `CFLAGS` +are supported; the runner also forces `CCACHE_DISABLE=1` for child processes. + +Coverage: + +- NULL free, malloc/realloc NULL and zero-size delegation, secure zero-size free. +- Rounded usable capacity larger than the original request; whole-capacity wipe + checked **before** the fake heap actually frees the backing allocation. +- Equal-capacity and shrink pointer retention, discarded-tail wiping, unchanged + prefix, retained capacity and logical regrowth without allocation. +- Growth copies every byte of the old usable extent, including rounding, without + over-copying into the new suffix. Both allocations are live during growth. +- PSRAM-first/internal-fallback capability order on every allocation; successful + fallback and migration back to preferred PSRAM on a later growth. +- Failed allocation/growth leaves the old pointer and full contents live and + unchanged, with no SDK realloc fallback (none is supplied by the test). +- Base-pointer-only extent queries, live-pointer checks, aligned payloads and + prefix/suffix guards, request sizes unchanged including `SIZE_MAX`. +- Six rejected poisoning configurations, explicit-zero inactive options, and + four rejected IDF versions. The supported profile is unpoisoned IDF 5.5.0. +- Optional exact normalized function-body contracts for installed heap extent + queries/TLSF size accessor, public declaration and implementation alias; compile + the module with the installed IDF version header. This is a narrow source + contract check, not execution of the target SDK heap or a complete heap audit. + +## Integration and limits + +The parent must add `ssh_memory.c` to its build and register these three hooks +before wolfSSH/wolfSSL allocations begin. This change does not integrate them. + +There are no production headers preceding allocations, metadata tables, locks or +additional tasks. Allocator alignment and allocation-size failure semantics are +preserved by passing the size straight to `heap_caps_malloc_prefer()`. A shrink +retains capacity rather than reclaiming heap; growth temporarily needs old plus +new allocations. PSRAM remains preferred, but internal fallback can transiently +need the full new allocation while the old one is still live. No runtime reserve +or hardware performance claim follows from these host tests. + +Heap poisoning is deliberately unsupported: its canary layout is not compatible +with blindly wiping a rounded extent. The version guard requires re-audit on SDK +updates. Cleanup covers retired allocations and explicit realloc tails, not +still-live library buffers, parser spans, stack temporaries or all library +secrets. Hardware validation is deferred to whole-phase testing. diff --git a/tests/ssh_memory/run.py b/tests/ssh_memory/run.py new file mode 100644 index 0000000..6400474 --- /dev/null +++ b/tests/ssh_memory/run.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Build actual ssh_memory.c with a guarded heap double; no device/network work.""" +import argparse +import os +from pathlib import Path +import re +import shlex +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +ENV = dict(os.environ, CCACHE_DISABLE="1") + + +def function(source, name): + match = re.search(r"\b" + re.escape(name) + r"\s*\([^;{}]*\)\s*\{", source) + if not match: + raise AssertionError(f"Missing function {name}") + start = source.index("{", match.start()) + depth = 1 + end = start + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[match.start():end] + + +def normalized(text): + text = re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S) + return re.sub(r"\s+", "", text) + + +def sdk_contract(sdk): + heap = sdk / "components/heap" + contracts = [ + ("heap_caps.c", "heap_caps_get_allocated_size", """ + heap_caps_get_allocated_size(void *ptr) { + ptr = MULTI_HEAP_REMOVE_BLOCK_OWNER_OFFSET(ptr); + heap_t *heap = find_containing_heap(ptr); + assert(heap); + size_t size = multi_heap_get_allocated_size(heap->heap, ptr); + return MULTI_HEAP_REMOVE_BLOCK_OWNER_SIZE(size); + }"""), + ("multi_heap.c", "multi_heap_get_allocated_size_impl", """ + multi_heap_get_allocated_size_impl(multi_heap_handle_t heap, void *p) { + return tlsf_block_size(p); + }"""), + ("tlsf/tlsf.c", "tlsf_block_size", """ + tlsf_block_size(void* ptr) { + size_t size = 0; + if (ptr) { + const block_header_t* block = block_from_ptr(ptr); + size = block_size(block); + } + return size; + }"""), + ] + for path, name, expected in contracts: + actual = function((heap / path).read_text(), name) + assert normalized(actual) == normalized(expected), f"Reaudit {path}:{name}" + multi = normalized((heap / "multi_heap.c").read_text()) + assert normalized('size_t multi_heap_get_allocated_size(multi_heap_handle_t heap, void *p) ' + '__attribute__((alias("multi_heap_get_allocated_size_impl")));') in multi + header = (heap / "include/esp_heap_caps.h").read_text() + assert "size_t heap_caps_get_allocated_size(void *ptr);" in header + print("PASS installed SDK source contract: extent query, multi_heap alias, TLSF size accessor") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--idf-path", type=Path, help="also check installed IDF extent source and compile with its version header") + args = parser.parse_args() + cc = shlex.split(ENV.get("CC", "cc")) + with tempfile.TemporaryDirectory(prefix="ssh-memory-") as temporary: + directory = Path(temporary) + (directory / "esp_err.h").write_text("typedef int esp_err_t;\n") + (directory / "esp_heap_caps.h").write_text(""" +#pragma once +#include +#define MALLOC_CAP_SPIRAM (1U << 10) +#define MALLOC_CAP_INTERNAL (1U << 11) +#define MALLOC_CAP_8BIT (1U << 2) +void *heap_caps_malloc_prefer(size_t size, size_t count, ...); +size_t heap_caps_get_allocated_size(void *pointer); +void heap_caps_free(void *pointer); +""") + version = """ +#define ESP_IDF_VERSION_VAL(a,b,c) (((a) << 16) | ((b) << 8) | (c)) +#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(5,5,0) +""" + version_header = directory / "esp_idf_version.h" + version_header.write_text(version) + config = directory / "sdkconfig.h" + config.write_text("#define CONFIG_HEAP_POISONING_DISABLED 1\n") + # Use the real volatile wipe body, without pulling in unrelated DRBG/IDF. + wipe = function((ROOT / "src/secure_random.c").read_text(), "secure_wipe") + (directory / "wipe.c").write_text("#include \n#include \nvoid " + wipe + "\n") + common = cc + ["-std=c11", "-Wall", "-Wextra", "-Werror", "-pedantic", + *shlex.split(ENV.get("CFLAGS", "-O2")), + "-I", str(directory), "-I", str(ROOT / "src")] + source = str(ROOT / "src/ssh_memory.c") + binary = directory / "test" + subprocess.run(common + [source, str(ROOT / "tests/ssh_memory/test.c"), + str(directory / "wipe.c"), "-o", str(binary)], env=ENV, check=True) + subprocess.run([str(binary)], env=ENV, check=True) + + def compile_only(expected_error=None): + result = subprocess.run(common + [source, "-c", "-o", str(directory / "memory.o")], + env=ENV, text=True, capture_output=True) + if expected_error is None: + assert result.returncode == 0, result.stderr + else: + assert result.returncode != 0 and expected_error in result.stderr, result.stderr + + for flags in ("", "#define CONFIG_HEAP_POISONING_DISABLED 0\n", + "#define CONFIG_HEAP_POISONING_LIGHT 1\n", + "#define CONFIG_HEAP_POISONING_COMPREHENSIVE 1\n", + "#define CONFIG_HEAP_POISONING_DISABLED 1\n#define CONFIG_HEAP_POISONING_LIGHT 1\n", + "#define CONFIG_HEAP_POISONING_DISABLED 1\n#define CONFIG_HEAP_POISONING_COMPREHENSIVE 1\n"): + config.write_text(flags) + compile_only("SSH memory requires heap poisoning disabled") + config.write_text("#define CONFIG_HEAP_POISONING_DISABLED 1\n" + "#define CONFIG_HEAP_POISONING_LIGHT 0\n" + "#define CONFIG_HEAP_POISONING_COMPREHENSIVE 0\n") + compile_only() + for unsupported in ("5,4,0", "5,5,1", "5,6,0", "6,0,0"): + version_header.write_text(version.replace("5,5,0", unsupported)) + compile_only("Reaudit SSH memory usable extent contract for this IDF") + version_header.write_text(version) + print("PASS compile guards: 6 invalid poisoning profiles, explicit disabled profile, 4 unsupported IDF versions") + if args.idf_path: + sdk_contract(args.idf_path) + version_header.write_text((args.idf_path / "components/esp_common/include/esp_idf_version.h").read_text()) + compile_only() + print("PASS actual module compile with installed IDF version header") + else: + print("SKIP installed SDK source contract (provide --idf-path)") + + +if __name__ == "__main__": + main() diff --git a/tests/ssh_memory/test.c b/tests/ssh_memory/test.c new file mode 100644 index 0000000..96d645b --- /dev/null +++ b/tests/ssh_memory/test.c @@ -0,0 +1,214 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "ssh_memory.h" +#include "esp_heap_caps.h" + +#include +#include +#include +#include +#include +#include +#include + +/* All payloads have guarded rounded capacity and normal malloc alignment. */ +#define ALIGNMENT _Alignof(max_align_t) +#define GUARD (2U * sizeof(max_align_t)) +#define BLOCKS 8U +#define LIMIT 4096U + +typedef struct { + unsigned char *raw; + unsigned char *base; + size_t capacity; + bool internal; +} block_t; + +static block_t blocks[BLOCKS]; +static bool fail_psram; +static bool fail_internal; +static unsigned allocations, releases, queries, psram_attempts, internal_attempts; +static unsigned live, peak_live; +static size_t last_request; + +static block_t *lookup(void *pointer) +{ + assert(pointer != NULL); + for (size_t i = 0; i < BLOCKS; ++i) { + if (blocks[i].base == pointer) return &blocks[i]; + } + assert(!"not a live allocation base"); + abort(); +} + +static void bytes_are(const unsigned char *p, size_t size, unsigned char value) +{ + for (size_t i = 0; i < size; ++i) assert(p[i] == value); +} + +static void guards(const block_t *block) +{ + bytes_are(block->raw, GUARD, 0xD3); + bytes_are(block->base + block->capacity, GUARD, 0xD3); +} + +void *heap_caps_malloc_prefer(size_t size, size_t count, ...) +{ + va_list args; + va_start(args, count); + assert(count == 2U); + assert(va_arg(args, unsigned int) == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + assert(va_arg(args, unsigned int) == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + va_end(args); + ++allocations; + last_request = size; + /* The module must pass size unchanged, including zero and SIZE_MAX. */ + if (size == 0U) return NULL; + ++psram_attempts; + bool internal = fail_psram || size > LIMIT; + if (internal) { + ++internal_attempts; + if (fail_internal || size > LIMIT) return NULL; + } + size_t capacity = ((size + ALIGNMENT - 1U) / ALIGNMENT) * ALIGNMENT; + for (size_t i = 0; i < BLOCKS; ++i) { + block_t *block = &blocks[i]; + if (block->base != NULL) continue; + block->raw = malloc(GUARD + capacity + GUARD); + assert(block->raw != NULL); + block->base = block->raw + GUARD; + block->capacity = capacity; + block->internal = internal; + memset(block->raw, 0xD3, GUARD + capacity + GUARD); + memset(block->base, 0xA5, capacity); + assert((uintptr_t)block->base % ALIGNMENT == 0U); + ++live; + if (live > peak_live) peak_live = live; + return block->base; + } + assert(!"fake heap exhausted"); + return NULL; +} + +size_t heap_caps_get_allocated_size(void *pointer) +{ + ++queries; + block_t *block = lookup(pointer); + guards(block); + return block->capacity; +} + +void heap_caps_free(void *pointer) +{ + block_t *block = lookup(pointer); + guards(block); + /* Inspect BEFORE real free: no reads through dangling pointers. */ + bytes_are(block->base, block->capacity, 0); + free(block->raw); + memset(block, 0, sizeof(*block)); + ++releases; + --live; +} + +static void test_null_zero(void) +{ + unsigned before = queries; + ssh_memory_free(NULL); + assert(queries == before && releases == 0U); + assert(ssh_memory_malloc(0) == NULL && last_request == 0U); + unsigned calls = allocations; + assert(ssh_memory_realloc(NULL, 0) == NULL); + assert(allocations == calls + 1U && queries == before); + void *p = ssh_memory_realloc(NULL, 7); + assert(p != NULL && last_request == 7U); + assert(ssh_memory_realloc(p, 0) == NULL && live == 0U); +} + +static void test_retained_capacity(void) +{ + unsigned char *p = ssh_memory_malloc(17); + block_t *block = lookup(p); + size_t capacity = block->capacity; + assert(capacity > 17U); + memset(p, 0x71, capacity); + unsigned calls = allocations; + assert(ssh_memory_realloc(p, capacity) == p); + bytes_are(p, capacity, 0x71); + assert(ssh_memory_realloc(p, 17) == p); + bytes_are(p, 17, 0x71); + bytes_are(p + 17, capacity - 17, 0); + assert(ssh_memory_realloc(p, 5) == p); + bytes_are(p, 5, 0x71); + bytes_are(p + 5, capacity - 5, 0); + /* Logical regrowth within retained capacity allocates nothing. */ + assert(ssh_memory_realloc(p, capacity - 1U) == p); + bytes_are(p, 5, 0x71); + bytes_are(p + 5, capacity - 5, 0); + assert(block->capacity == capacity && allocations == calls); + guards(block); + ssh_memory_free(p); +} + +static void test_growth_and_failure(void) +{ + unsigned char *p = ssh_memory_malloc(17); + size_t capacity = lookup(p)->capacity; + for (size_t i = 0; i < capacity; ++i) p[i] = (unsigned char)(i + 1U); + fail_psram = fail_internal = true; + unsigned freed = releases; + assert(ssh_memory_realloc(p, capacity + 1U) == NULL); + assert(releases == freed && live == 1U); + for (size_t i = 0; i < capacity; ++i) assert(p[i] == (unsigned char)(i + 1U)); + guards(lookup(p)); + fail_internal = false; + unsigned char *q = ssh_memory_realloc(p, capacity + 1U); + assert(q != NULL && lookup(q)->internal); + assert(last_request == capacity + 1U && releases == freed + 1U); + assert(live == 1U && peak_live == 2U); + for (size_t i = 0; i < capacity; ++i) assert(q[i] == (unsigned char)(i + 1U)); + /* The new suffix isn't promised zero; ensure no over-copy either. */ + bytes_are(q + capacity, lookup(q)->capacity - capacity, 0xA5); + fail_psram = false; + size_t old_capacity = lookup(q)->capacity; + memset(q, 0x69, old_capacity); + unsigned char *r = ssh_memory_realloc(q, old_capacity + 19U); + assert(r != NULL && !lookup(r)->internal); + bytes_are(r, old_capacity, 0x69); + ssh_memory_free(r); +} + +static void test_sizes_alignment_and_preference(void) +{ + for (size_t size = 1; size <= 129; ++size) { + fail_psram = (size % 2U) != 0U; + unsigned external_before = psram_attempts; + unsigned internal_before = internal_attempts; + void *p = ssh_memory_malloc(size); + assert(last_request == size && lookup(p)->internal == fail_psram); + assert(psram_attempts == external_before + 1U); + assert(internal_attempts == internal_before + (fail_psram ? 1U : 0U)); + assert((uintptr_t)p % ALIGNMENT == 0U); + ssh_memory_free(p); + } + fail_psram = fail_internal = true; + assert(ssh_memory_malloc(33) == NULL); + fail_psram = fail_internal = false; + assert(ssh_memory_malloc(SIZE_MAX) == NULL && last_request == SIZE_MAX); + unsigned char *p = ssh_memory_malloc(9); + size_t capacity = lookup(p)->capacity; + memset(p, 0x81, capacity); + assert(ssh_memory_realloc(p, SIZE_MAX) == NULL && last_request == SIZE_MAX); + bytes_are(p, capacity, 0x81); + guards(lookup(p)); + ssh_memory_free(p); +} + +int main(void) +{ + test_null_zero(); + test_retained_capacity(); + test_growth_and_failure(); + test_sizes_alignment_and_preference(); + assert(live == 0U); + puts("PASS ssh_memory: null/zero, rounded extent, retained shrink/equal, growth, failure, caps, alignment, guards"); + return 0; +} diff --git a/tests/ssh_protocol_policy/README.md b/tests/ssh_protocol_policy/README.md new file mode 100644 index 0000000..bcd1000 --- /dev/null +++ b/tests/ssh_protocol_policy/README.md @@ -0,0 +1,141 @@ +# SSH protocol policy contracts + +Run from the project root: + +```sh +CCACHE_DISABLE=1 python3 tests/ssh_protocol_policy/run.py +``` + +The runner requires the installed, exactly pinned wolfSSH 1.4.20 source, the +production compilation database, its target compiler, Python 3, and a host C99 +compiler (`CC`, default `cc`). It never downloads dependencies or invokes a +firmware build. All host headers, extracted functions, and binaries are created +in a temporary directory and removed afterward. Every compiler/preprocessor +invocation is limited to 30 seconds; each host executable to 10 seconds. +`CCACHE_DISABLE=1` is also forced for subprocesses. + +If multiple build environments exist, select the intended profile explicitly: + +```sh +CCACHE_DISABLE=1 python3 tests/ssh_protocol_policy/run.py \ + --compile-commands .pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json +``` + +There is deliberately no host-only mode that could silently skip resolved +production-feature verification. The database must select exactly one generated +`security_overrides/wolfssh_internal/internal.c`, whose bytes must equal the +in-memory `tools/security_overrides.py` `render_entry()` output. Original vendor +compilation, missing/duplicate entries, and stale generated content fail closed +with a reconfiguration diagnostic. The runner does not regenerate anything. +The helper is syntax-checked against real target headers using that generated +translation unit's compile settings. + +## Production contract and integration + +`src/ssh_protocol_policy.{c,h}` exports: + +```c +int ssh_protocol_policy_apply(WOLFSSH_CTX *context); +``` + +It applies permanent, borrowed strings for these five context settings: + +| Setter suffix | Exact value | +|---|---| +| `Kex` | `curve25519-sha256,ecdh-sha2-nistp256` | +| `Key` | `ecdsa-sha2-nistp256` | +| `Cipher` | `aes128-gcm@openssh.com,aes256-gcm@openssh.com` | +| `Mac` | `hmac-sha2-256` | +| `KeyAccepted` | `ssh-ed25519,ecdsa-sha2-nistp256` | + +The helper returns `WS_SSH_CTX_NULL_E` for NULL and otherwise returns the first +non-success setter result, without subsequent calls or fallback. It does not +allocate, free, or publish a context. A failure can leave earlier settings +applied: **the caller must discard the candidate, not use it**. + +The parent integrated the helper in `create_context()` after host-key import and +full staging-buffer wipe, before callback registration and `s_context` +publication. Any policy failure frees the unpublished candidate and returns +`ESP_FAIL`. `context.c` now executes the actual extracted function with the real +policy helper to test this boundary. Service startup isolation, owner/task +lifecycle, session creation, and complete restart paths remain outside this +focused harness. + +The exact-version guard rejects unreviewed wolfSSH versions. The source/config +checks below independently verify the actual feature profile; setter success +alone does not validate an algorithm list. `KeyAccepted` controls only the +`server-sig-algs` advertisement in this vendor version. User-key enrollment and +authorization remain enforced by the existing database/authentication path. + +## Evidence provided + +- Pins the SHA-256 of installed `src/internal.c` and `src/ssh.c`, the application + manifest's exact wolfSSH version, and the resolved compiler version macro. + Source changes require re-audit, not blind hash refresh. Independently checks + the registered override's original-source hash, renders it in memory, and + requires exact equality with the actual generated compiler input. Requires + all original algorithm tables/default strings and extracted protocol-function + bodies to remain unchanged by the override. Negative database cases reject + original/missing/duplicate entries and mismatched render output. +- Replays the actual generated vendor compile command without output/dependency-writing + flags to resolve feature macros, the name/ID/type map, and conditional enums. + All seven distinct policy algorithm names must have their expected compiled + IDs and categories. Required RNG/software-crypto and Ed25519 streaming settings + must remain present; policy-disabling macros are rejected. Negative map cases + demonstrate that missing algorithms fail the checker. +- Compiles the real helper against injected setter doubles. Tests NULL without + dispatch, all five exact lists in order, negative and positive non-success + return propagation at every step, no later calls/fallback, preservation of + unapplied fields, context sentinel survival, and retained string pointers. + These remain helper-boundary checks; the separate context harness below tests + actual caller cleanup and publication. +- Executes actual extracted `src/ssh_transport.c` `create_context()` together + with the real policy helper: 15 cases covering identity-copy failure, + context-allocation failure, both positive/negative key-import failures, + positive/negative setter failures at all five steps, and success. Checks error + mapping, no later policy calls/callbacks/publication on failure, exactly one + free for allocated failed candidates, no free on success, and publication only + after all eight callback registrations. Marks the entire synthetic identity + staging buffer, including the unused tail; asserts full-capacity wipe before + policy initialization, callbacks, and candidate destruction. Context creation + and key import necessarily precede that wipe. Buffer checks occur only while + the extracted function's stack frame is live, never after return. +- Executes the five actual vendor context setter bodies, confirming null-context + errors and their acceptance of invalid, empty, and NULL lists. The helper then + overwrites all five with the fixed policy. +- Executes actual vendor `NameToId`, `IdToName`, `AlgoListSz`, `CopyNameList`, + `CopyNameListPlus`, `BuildNameList`, `SendKexInit`, and `SendExtInfo` bodies. + The mapping and enum values come from production preprocessing. The five actual + `SshInit` list-pointer assignments are checked and reused in the reduced layout. +- Independently decodes initial and repeated/rekey KEXINIT plaintext payloads: + exact KEX/host-key lists, both cipher directions, both MAC directions, + compression/language lists, first-packet flag, reserved field, total bounds, + canaries, and saved exchange-hash input. Exact equality excludes CBC, CTR, + AES192, extra KEX/MAC entries, or an appended default fallback. +- Decodes the actual `server-sig-algs` extension with exactly Ed25519/P-256. +- Exercises missing-host-key, packet-preparation, saved-payload allocation, and + WANT_WRITE behavior with bounded doubles. Checks no send on early failures + and preservation of the exact payload on WANT_WRITE. +- Compiles negative older/newer version cases against the production guard. + +## Limits and deferred validation + +Host context/session layouts are reduced doubles, not vendor ABI replicas. +The context harness doubles identity copying, key import, context allocation/free, +callback registration, and the wipe primitive. It verifies production call order, +wipe extent, cleanup, and publication, not vendor destruction or secure-wipe +machine code. Synthetic identity bytes are not actual private-key material. +Handshake allocation, packet reservation/wrapping/purging, deterministic cookie +RNG, big-endian integer writing, and send are doubles; payload encoders and list +setters are extracted vendor code. The fixed packet/storage buffers are 1024 +bytes. No private key, signature, KEX arithmetic, encryption, MAC, complete SSH +packet framing, peer negotiation, socket, device, or scheduling behavior is +executed. Repeated KEXINIT tests serialization on rekey, not an entire rekey +exchange. The extension test does not establish user-key enforcement. + +Hardware/live-client acceptance remains deferred to combined Phase 9: both key +types, explicit rejection of excluded algorithms, real rekey, and mixed +transport responsiveness. No negotiated-handshake or target pass is implied. + +This work does not change TLS policy, dependencies, vendor files, generated +assets, global crypto primitives/settings, NVS encryption, or eFuses. diff --git a/tests/ssh_protocol_policy/apply.c b/tests/ssh_protocol_policy/apply.c new file mode 100644 index 0000000..1040674 --- /dev/null +++ b/tests/ssh_protocol_policy/apply.c @@ -0,0 +1,92 @@ +#include +#include +#include +#include "ssh_protocol_policy.h" + +static const char *const expected[] = { + "curve25519-sha256,ecdh-sha2-nistp256", + "ecdsa-sha2-nistp256", + "aes128-gcm@openssh.com,aes256-gcm@openssh.com", + "hmac-sha2-256", + "ssh-ed25519,ecdsa-sha2-nistp256", +}; +static unsigned calls, fail_at; +static int failure; +static WOLFSSH_CTX *candidate; + +static int set(WOLFSSH_CTX *ctx, const char *list, const char **field, + unsigned step) +{ + assert(ctx == candidate); + assert(ctx->sentinel == 0x12345678U); + assert(++calls == step); + assert(strcmp(list, expected[step - 1]) == 0); + if (step == fail_at) return failure; + *field = list; + return WS_SUCCESS; +} +#define SETTER(name, field, step) \ + int wolfSSH_CTX_SetAlgoList##name(WOLFSSH_CTX *c, const char *s) \ + { return set(c, s, &c->field, step); } +SETTER(Kex, algoListKex, 1) +SETTER(Key, algoListKey, 2) +SETTER(Cipher, algoListCipher, 3) +SETTER(Mac, algoListMac, 4) +SETTER(KeyAccepted, algoListKeyAccepted, 5) + +static const char *get(const WOLFSSH_CTX *c, unsigned index) +{ + switch (index) { + case 0: return c->algoListKex; + case 1: return c->algoListKey; + case 2: return c->algoListCipher; + case 3: return c->algoListMac; + default: return c->algoListKeyAccepted; + } +} + +int main(void) +{ + assert(ssh_protocol_policy_apply(NULL) == WS_SSH_CTX_NULL_E); + assert(calls == 0); + /* This is an unpublished stack-owned candidate. Any attempted vendor free + * has no test definition and fails linking; transport publication is outside + * this helper and is covered by the parent's integration tests. */ + static const char original[] = "old-default"; + for (unsigned step = 1; step <= 5; ++step) { + for (unsigned sign = 0; sign < 2; ++sign) { + WOLFSSH_CTX ctx = { + .algoListKex = original, .algoListKey = original, + .algoListCipher = original, .algoListMac = original, + .algoListKeyAccepted = original, .sentinel = 0x12345678U, + }; + candidate = &ctx; + calls = 0; + fail_at = step; + failure = sign ? (int)(9000 + step) : -(int)(9000 + step); + assert(ssh_protocol_policy_apply(&ctx) == failure); + assert(calls == step); + assert(ctx.sentinel == 0x12345678U); + for (unsigned i = 0; i < 5; ++i) { + if (i + 1 < step) assert(strcmp(get(&ctx, i), expected[i]) == 0); + else assert(get(&ctx, i) == original); + } + } + } + const char *retained[5]; + for (unsigned pass = 0; pass < 2; ++pass) { + WOLFSSH_CTX ctx = {.sentinel = 0x12345678U}; + candidate = &ctx; + calls = fail_at = 0; + assert(ssh_protocol_policy_apply(&ctx) == WS_SUCCESS); + assert(calls == 5); + for (unsigned i = 0; i < 5; ++i) { + assert(strcmp(get(&ctx, i), expected[i]) == 0); + if (pass == 0) retained[i] = get(&ctx, i); + else assert(retained[i] == get(&ctx, i)); + } + } + for (unsigned i = 0; i < 5; ++i) assert(strcmp(retained[i], expected[i]) == 0); + puts("PASS: apply NULL, five exact lists, every setter failure (+/-), stop/no fallback, retained strings"); + return 0; +} diff --git a/tests/ssh_protocol_policy/context.c b/tests/ssh_protocol_policy/context.c new file mode 100644 index 0000000..9c26b97 --- /dev/null +++ b/tests/ssh_protocol_policy/context.c @@ -0,0 +1,182 @@ +/* Actual create_context() + actual policy; identity/vendor/callback doubles. */ +#include +#include +#include +#include "ssh_protocol_policy.h" +#include "context_constants.h" + +typedef int esp_err_t; +enum { ESP_OK = 0, ESP_FAIL = -1, ESP_ERR_NO_MEM = 0x101 }; +static WOLFSSH_CTX candidate, *s_context; +static unsigned copies, news, imports, wipes, setters, callbacks, frees; +static unsigned fail_setter; +static int copy_error, allocation_fail, import_error, setter_error; +static unsigned char *identity; +static size_t identity_capacity; + +static void unpublished(void) +{ + assert(s_context == NULL); +} +static void wiped(void) +{ + assert(wipes == 1); + assert(identity_capacity == SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY); + /* Only called while the extracted create_context stack frame is alive. */ + for (size_t i = 0; i < identity_capacity; ++i) assert(identity[i] == 0); +} +static esp_err_t ssh_security_copy_private_key(unsigned char *out, size_t capacity, + size_t *length) +{ + unpublished(); + assert(++copies == 1); + assert(capacity == SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY); + for (size_t i = 0; i < capacity; ++i) assert(out[i] == 0); + identity = out; + identity_capacity = capacity; + /* Mark the unused tail too, so a short wipe cannot pass this test. */ + memset(out, 0x6d, capacity); + *length = 31; + return copy_error; +} +static void secure_wipe(void *ptr, size_t length) +{ + unpublished(); + assert(ptr == identity && length == identity_capacity); + assert(wipes++ == 0); + memset(ptr, 0, length); +} +static WOLFSSH_CTX *wolfSSH_CTX_new(int endpoint, void *heap) +{ + unpublished(); + assert(copies == 1 && wipes == 0 && imports == 0); + assert(endpoint == WOLFSSH_ENDPOINT_SERVER && heap == NULL); + assert(++news == 1); + return allocation_fail ? NULL : &candidate; +} +static int wolfSSH_CTX_UsePrivateKey_buffer(WOLFSSH_CTX *ctx, + const unsigned char *key, word32 length, int format) +{ + unpublished(); + assert(ctx == &candidate && news == 1 && wipes == 0); + assert(++imports == 1); + assert(key == identity && length == 31 && format == WOLFSSH_FORMAT_ASN1); + for (size_t i = 0; i < identity_capacity; ++i) assert(key[i] == 0x6d); + return import_error; +} +static void wolfSSH_CTX_free(WOLFSSH_CTX *ctx) +{ + unpublished(); + wiped(); + assert(ctx == &candidate && news == 1 && callbacks == 0); + assert(++frees == 1); +} +static int set_list(WOLFSSH_CTX *ctx, const char *list, const char **field, + unsigned step) +{ + unpublished(); + wiped(); + assert(imports == 1 && import_error == 0 && frees == 0 && callbacks == 0); + assert(ctx == &candidate && ++setters == step); + assert(list != NULL && list[0] != '\0'); + if (step == fail_setter) return setter_error; + *field = list; + return WS_SUCCESS; +} +#define SETTER(name, field, step) \ + int wolfSSH_CTX_SetAlgoList##name(WOLFSSH_CTX *ctx, const char *list) \ + { return set_list(ctx, list, &ctx->field, step); } +SETTER(Kex, algoListKex, 1) +SETTER(Key, algoListKey, 2) +SETTER(Cipher, algoListCipher, 3) +SETTER(Mac, algoListMac, 4) +SETTER(KeyAccepted, algoListKeyAccepted, 5) + +static void bounded_ssh_receive(void) {} +static void authenticate_user(void) {} +static void allowed_auth_types(void) {} +static void authentication_result(void) {} +static void reject_keyboard_auth(void) {} +static void accept_shell(void) {} +static void reject_channel_request(void) {} +static int callback(WOLFSSH_CTX *ctx, void (*actual)(void), + void (*expected)(void), unsigned step) +{ + unpublished(); + wiped(); + assert(ctx == &candidate && setters == 5 && fail_setter == 0 && frees == 0); + assert(++callbacks == step && actual == expected); + return WS_SUCCESS; +} +#define CALLBACK(name, expected, step) \ + static int name(WOLFSSH_CTX *ctx, void (*cb)(void)) \ + { return callback(ctx, cb, expected, step); } +CALLBACK(wolfSSH_SetIORecv, bounded_ssh_receive, 1) +CALLBACK(wolfSSH_SetUserAuth, authenticate_user, 2) +CALLBACK(wolfSSH_SetUserAuthTypes, allowed_auth_types, 3) +CALLBACK(wolfSSH_SetUserAuthResult, authentication_result, 4) +CALLBACK(wolfSSH_SetKeyboardAuthPrompts, reject_keyboard_auth, 5) +CALLBACK(wolfSSH_CTX_SetChannelReqShellCb, accept_shell, 6) +CALLBACK(wolfSSH_CTX_SetChannelReqExecCb, reject_channel_request, 7) +CALLBACK(wolfSSH_CTX_SetChannelReqSubsysCb, reject_channel_request, 8) + +#include "context_actual.c" + +static void reset(void) +{ + memset(&candidate, 0, sizeof(candidate)); + s_context = NULL; + copies = news = imports = wipes = setters = callbacks = frees = 0; + fail_setter = 0; + copy_error = allocation_fail = import_error = setter_error = 0; + identity = NULL; + identity_capacity = 0; +} +static void failed(int expected) +{ + assert(create_context() == expected); + assert(s_context == NULL && callbacks == 0 && wipes == 1 && copies == 1); + /* identity points at a retired stack frame now: never inspect it here. */ + identity = NULL; +} +int main(void) +{ + unsigned cases = 0; + reset(); + copy_error = 0x4321; + failed(copy_error); + assert(news == 0 && imports == 0 && setters == 0 && frees == 0); + ++cases; + reset(); + allocation_fail = 1; + failed(ESP_ERR_NO_MEM); + assert(news == 1 && imports == 0 && setters == 0 && frees == 0); + ++cases; + for (unsigned sign = 0; sign < 2; ++sign) { + reset(); + import_error = sign ? 7001 : -7001; + failed(ESP_FAIL); + assert(news == 1 && imports == 1 && setters == 0 && frees == 1); + ++cases; + } + for (unsigned step = 1; step <= 5; ++step) { + for (unsigned sign = 0; sign < 2; ++sign) { + reset(); + fail_setter = step; + setter_error = sign ? (int)(8000 + step) : -(int)(8000 + step); + failed(ESP_FAIL); + assert(news == 1 && imports == 1 && setters == step && frees == 1); + ++cases; + } + } + reset(); + assert(create_context() == ESP_OK); + identity = NULL; + assert(s_context == &candidate); + assert(copies == 1 && news == 1 && imports == 1 && wipes == 1); + assert(setters == 5 && callbacks == 8 && frees == 0); + ++cases; + assert(cases == 15); + puts("PASS: actual create_context + policy: 15 cases, full identity wipe before policy/callbacks/free, exact cleanup, publication only after success"); + return 0; +} diff --git a/tests/ssh_protocol_policy/run.py b/tests/ssh_protocol_policy/run.py new file mode 100644 index 0000000..894cbff --- /dev/null +++ b/tests/ssh_protocol_policy/run.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Bounded, offline policy/vendor contracts using the production compile profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +VENDOR = ROOT / "managed_components/wolfssl__wolfssh" +ENV = {**os.environ, "CCACHE_DISABLE": "1"} +sys.dont_write_bytecode = True +sys.path.insert(0, str(ROOT / "tools")) +from security_overrides import ENTRIES, render_entry +HASHES = { + "internal.c": "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9", + "ssh.c": "a4f479ff87eea0980ec1ebdf2c7dd090da473780181b695a56799cb9611f4366", +} +FIELDS = ("Kex", "Key", "Cipher", "Mac", "KeyAccepted") +REQUIRED = { + "curve25519-sha256": ("ID_CURVE25519_SHA256", "TYPE_KEX"), + "ecdh-sha2-nistp256": ("ID_ECDH_SHA2_NISTP256", "TYPE_KEX"), + "ecdsa-sha2-nistp256": ("ID_ECDSA_SHA2_NISTP256", "TYPE_KEY"), + "aes128-gcm@openssh.com": ("ID_AES128_GCM", "TYPE_CIPHER"), + "aes256-gcm@openssh.com": ("ID_AES256_GCM", "TYPE_CIPHER"), + "hmac-sha2-256": ("ID_HMAC_SHA2_256", "TYPE_MAC"), + "ssh-ed25519": ("ID_ED25519", "TYPE_KEY"), +} + + +def run(args, **kwargs): + return subprocess.run(args, env=ENV, timeout=30, check=True, **kwargs) + + +def extract(source, name): + # Mask comments/strings without changing offsets; match definitions only. + masked = re.sub(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'', + lambda m: " " * len(m[0]), source, flags=re.S) + pattern = (r"(?m)^(?:static )?(?:INLINE )?(?:const )?" + r"(?:int|void|byte|word32|char|esp_err_t)\s*\*?\s*" + re.escape(name) + + r"\s*\([^;{}]*\)\s*\{") + matches = list(re.finditer(pattern, masked)) + if len(matches) != 1: + raise RuntimeError(f"Expected one definition of {name}, got {len(matches)}") + start = matches[0].start() + end = masked.index("{", start) + 1 + depth = 1 + while depth: + depth += (masked[end] == "{") - (masked[end] == "}") + end += 1 + return source[start:end] + "\n" + + +def source_path(entry): + return (Path(entry["directory"]) / entry["file"]).resolve() + + +def compiler_command(database, override, expected): + entries = json.loads(database.read_text()) + original = (VENDOR / "src/internal.c").resolve() + if any(source_path(e) == original for e in entries): + raise RuntimeError("Production still compiles original internal.c; reconfigure the build") + suffix = ("security_overrides", override.name, "internal.c") + matches = [e for e in entries if source_path(e).parts[-3:] == suffix] + if len(matches) != 1: + raise RuntimeError(f"Expected one generated wolfSSH compile entry, found {len(matches)}") + entry = matches[0] + actual = source_path(entry).read_bytes() + if actual != expected: + raise RuntimeError("Generated wolfSSH source differs from render_entry; reconfigure the build") + args = entry.get("arguments") or shlex.split(entry["command"]) + clean = [] + skip = False + for arg in args: + if skip: + skip = False + elif arg in ("-o", "-MF", "-MT", "-MQ"): + skip = True + elif arg not in ("-c", "-MD", "-MMD", "-MP"): + clean.append(arg) + return entry, clean + + +def check_profile(macros, mapping): + if macros.get("LIBWOLFSSH_VERSION_HEX") != "0x01004020": + raise RuntimeError("Expected reviewed wolfSSH 1.4.20 compiler profile") + required = ("WC_RNG_SEED_CB", "NO_WOLFSSL_ESP32_CRYPT_AES", + "NO_WOLFSSL_ESP32_CRYPT_HASH", "WOLFSSL_ED25519_STREAMING_VERIFY", + "HAVE_CURVE25519", "HAVE_ECC", "HAVE_ED25519", "HAVE_AESGCM") + for name in required: + if name not in macros: + raise RuntimeError(f"Required resolved crypto/RNG feature missing: {name}") + disabled = ("WOLFSSH_NO_CURVE25519_SHA256", "WOLFSSH_NO_ECDH_SHA2_NISTP256", + "WOLFSSH_NO_ECDSA_SHA2_NISTP256", "WOLFSSH_NO_AES_GCM", + "WOLFSSH_NO_HMAC_SHA2_256", "WOLFSSH_NO_ED25519") + for name in disabled: + if name in macros: + raise RuntimeError(f"Policy algorithm disabled: {name}") + for name, (identifier, category) in REQUIRED.items(): + row = r'\{\s*' + identifier + r'\s*,\s*' + category + r'\s*,\s*"' + re.escape(name) + r'"\s*\}' + if len(re.findall(row, mapping)) != 1: + raise RuntimeError(f"Missing/ambiguous resolved algorithm name/ID/type: {name}") + + +def enum_containing(source, token): + matches = [m[0] for m in re.finditer(r"(?m)^enum(?: \w+)?\s*\{[^{}]*\};", source) + if re.search(r"\b" + re.escape(token) + r"\b", m[0])] + if len(matches) != 1: + raise RuntimeError(f"Expected one resolved enum containing {token}") + return matches[0] + "\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + databases = sorted((ROOT / ".pio/build").glob("*/compile_commands.json")) + default = databases[0] if len(databases) == 1 else ROOT / "compile_commands.json" + parser.add_argument("--compile-commands", type=Path, default=default) + options = parser.parse_args() + sources = {} + for name, expected in HASHES.items(): + raw = (VENDOR / "src" / name).read_bytes() + if hashlib.sha256(raw).hexdigest() != expected: + raise RuntimeError(f"Vendor {name} changed; re-audit before updating pin") + sources[name] = raw.decode() + manifest = (ROOT / "src/idf_component.yml").read_text() + if not re.search(r'^\s*wolfssl/wolfssh:\s*"1\.4\.20"\s*$', manifest, re.M): + raise RuntimeError("Expected exact application wolfSSH 1.4.20 pin") + overrides = [e for e in ENTRIES if e.component == "wolfssl__wolfssh" and + e.source == "managed_components/wolfssl__wolfssh/src/internal.c"] + if (len(overrides) != 1 or overrides[0].root != "project" or + overrides[0].sha256 != HASHES["internal.c"]): + raise RuntimeError("Expected one independently pinned project wolfSSH override") + override = overrides[0] + _, expected = render_entry(override, {"project": ROOT}) + entry, command = compiler_command(options.compile_commands, override, expected) + internal = expected.decode() + # The generated memory-hardening changes must not silently change protocol + # defaults or the feature-filtered name/ID map independently of this policy. + for name, pattern in ( + ("NameIdMap", r"static const NameIdPair NameIdMap\[\].*?\n\};"), + *((name, r"static const char " + name + r"\[\].*?;") for name in + ("cannedKexAlgoNames", "cannedKeyAlgoNames", "cannedEncAlgoNames", + "cannedMacAlgoNames", "cannedNoneNames"))): + original = re.search(pattern, sources["internal.c"], re.S) + transformed = re.search(pattern, internal, re.S) + if original is None or transformed is None or original[0] != transformed[0]: + raise RuntimeError(f"Override changed reviewed algorithm definitions: {name}") + print("PASS: generated compiler input equals render_entry; original pinned algorithm tables unchanged", flush=True) + resolved = run(command + ["-E", "-P"], cwd=entry["directory"], + capture_output=True, text=True).stdout + macro_text = run(command + ["-E", "-dM"], cwd=entry["directory"], + capture_output=True, text=True).stdout + macros = dict(re.findall(r'^#define (\w+)(?: (.*))?$', macro_text, re.M)) + mapping = re.search(r'static const NameIdPair NameIdMap\[\]\s*=\s*\{.*?\n\};', + resolved, re.S)[0] + check_profile(macros, mapping) + # The feature checker must not turn into a support-only, always-green test. + for name in REQUIRED: + try: + check_profile(macros, mapping.replace('"' + name + '"', '"removed"')) + except RuntimeError: + pass + else: + raise AssertionError(f"Missing algorithm was not detected: {name}") + print("PASS: production compiler resolved all seven name/ID/type entries and required crypto/RNG features", flush=True) + + # Compile the helper against real target headers/settings, even before the + # parent has registered its translation unit in CMake. + target = [str(ROOT / "src/ssh_protocol_policy.c") if + arg == entry["file"] else arg for arg in command] + if target == command: + raise RuntimeError("Could not replace vendor input in compiler command") + run(target + ["-fsyntax-only"], cwd=entry["directory"], capture_output=True, text=True) + print("PASS: policy syntax with real target compiler and headers", flush=True) + + functions = ("NameToId", "IdToName", "AlgoListSz", "CopyNameList", + "CopyNameListPlus", "BuildNameList", "SendKexInit", "SendExtInfo") + actual = "\n".join(extract(sources["ssh.c"], "wolfSSH_CTX_SetAlgoList" + field) + for field in FIELDS) + for name in functions: + if extract(internal, name) != extract(sources["internal.c"], name): + raise RuntimeError(f"Override changed reviewed protocol function: {name}") + actual += "\n".join(extract(internal, name) for name in functions) + # Preserve actual conditional enum values and feature-filtered name table. + types = "\n".join(enum_containing(resolved, token) for token in + ("ID_NONE", "TYPE_KEX", "MSGID_KEXINIT", "WOLFSSH_ENDPOINT_SERVER")) + types += "typedef struct { byte id; byte type; const char *name; } NameIdPair;\n" + mapping + assignments = [] + for field in FIELDS: + line = f"ssh->algoList{field} = ctx->algoList{field};" + if resolved.count(line) != 1: + raise RuntimeError(f"Re-audit SshInit pointer inheritance: {field}") + assignments.append(line) + + with tempfile.TemporaryDirectory(prefix="ssh-protocol-policy-") as directory: + temp = Path(directory) + # Fail closed on stale/ambiguous databases and a stale generated render. + entries = json.loads(options.compile_commands.read_text()) + original_entry = {**entry, "file": str(VENDOR / "src/internal.c")} + database_cases = ( + (entries + [original_entry], expected), + ([e for e in entries if source_path(e) != source_path(entry)], expected), + (entries + [entry], expected), + (entries, expected + b"\n/* stale render */\n"), + ) + for index, (bad_entries, bad_expected) in enumerate(database_cases): + database = temp / f"bad-database-{index}.json" + database.write_text(json.dumps(bad_entries)) + try: + compiler_command(database, override, bad_expected) + except RuntimeError: + pass + else: + raise AssertionError(f"Unsafe generated compiler profile accepted: {index}") + print("PASS: original/missing/duplicate compile entries and stale render rejected", flush=True) + headers = temp / "wolfssh" + headers.mkdir() + (headers / "ssh.h").write_text('#include "support.h"\n') + (headers / "settings.h").write_text("/* Host layout double only. */\n") + for name in ("error.h", "version.h"): + shutil.copyfile(VENDOR / "wolfssh" / name, headers / name) + (temp / "resolved.h").write_text(types) + (temp / "vendor_actual.c").write_text(actual) + transport = (ROOT / "src/ssh_transport.c").read_text() + (temp / "context_actual.c").write_text(extract(transport, "create_context")) + security_header = (ROOT / "src/ssh_security.h").read_text() + capacity = re.search(r'^#define SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY\s+\d+U?$', + security_header, re.M) + if capacity is None: + raise RuntimeError("Re-audit private-key staging capacity definition") + (temp / "context_constants.h").write_text( + capacity[0] + "\n" + enum_containing(resolved, "WOLFSSH_ENDPOINT_SERVER") + + enum_containing(resolved, "WOLFSSH_FORMAT_ASN1")) + (temp / "session_lists.inc").write_text( + "{ WOLFSSH_CTX *ctx = context;\n" + "\n".join(assignments) + "\n}\n") + cc = shlex.split(os.environ.get("CC", "cc")) + flags = ["-std=c99", "-Wall", "-Wextra", "-Werror", "-I", str(temp), + "-I", str(HERE), "-I", str(ROOT / "src")] + policy = str(ROOT / "src/ssh_protocol_policy.c") + for name in ("apply", "context", "vendor"): + binary = temp / name + run(cc + flags + [policy, str(HERE / (name + ".c")), "-o", str(binary)]) + subprocess.run([str(binary)], env=ENV, check=True, timeout=10) + version = (headers / "version.h").read_text() + if '"1.4.20"' not in version or "0x01004020" not in version: + raise RuntimeError("Unexpected vendor version header") + for replacement in ("0x01004019", "0x01004021"): + (headers / "version.h").write_text(version.replace("0x01004020", replacement)) + result = subprocess.run(cc + flags + ["-fsyntax-only", policy], env=ENV, + capture_output=True, text=True, timeout=30) + if result.returncode == 0 or "Re-audit SSH protocol policy" not in result.stderr: + raise RuntimeError("Policy version guard did not reject unreviewed version") + print("PASS: older/newer wolfSSH versions rejected by production guard", flush=True) + print("PASS: source hashes, exact manifest pin; no downloads/build/device operations") + + +if __name__ == "__main__": + main() diff --git a/tests/ssh_protocol_policy/support.h b/tests/ssh_protocol_policy/support.h new file mode 100644 index 0000000..96d74d0 --- /dev/null +++ b/tests/ssh_protocol_policy/support.h @@ -0,0 +1,24 @@ +/* Reduced host layout only, not a wolfSSH ABI or crypto implementation. */ +#pragma once +#include +#include +#include + +typedef uint8_t byte; +typedef uint32_t word32; +typedef struct WOLFSSH_CTX { + const char *algoListKex, *algoListKey, *algoListCipher, *algoListMac; + const char *algoListKeyAccepted; + int side; + unsigned privateKeyCount; + void *heap; + byte publicKeyAlgo[8]; + word32 publicKeyAlgoCount; + unsigned sentinel; +} WOLFSSH_CTX; + +int wolfSSH_CTX_SetAlgoListKex(WOLFSSH_CTX *, const char *); +int wolfSSH_CTX_SetAlgoListKey(WOLFSSH_CTX *, const char *); +int wolfSSH_CTX_SetAlgoListCipher(WOLFSSH_CTX *, const char *); +int wolfSSH_CTX_SetAlgoListMac(WOLFSSH_CTX *, const char *); +int wolfSSH_CTX_SetAlgoListKeyAccepted(WOLFSSH_CTX *, const char *); diff --git a/tests/ssh_protocol_policy/vendor.c b/tests/ssh_protocol_policy/vendor.c new file mode 100644 index 0000000..00857d5 --- /dev/null +++ b/tests/ssh_protocol_policy/vendor.c @@ -0,0 +1,239 @@ +/* Actual vendor list/setter/serialization functions, with bounded host doubles. + * This tests plaintext SSH message payloads, not framing, crypto, or networking. */ +#include +#include +#include +#include "ssh_protocol_policy.h" +#include "resolved.h" + +#define WLOG(...) ((void)0) +#define INLINE inline +#define WMEMCPY memcpy +#define WSTRLEN strlen +#define XMEMCMP memcmp +#define WMALLOC(size, heap, type) bounded_alloc(size) +#define WFREE(ptr, heap, type) bounded_free(ptr) +#define MSG_ID_SZ 1U +#define UINT32_SZ 4U +#define LENGTH_SZ 4U +#define BOOLEAN_SZ 1U +#define COOKIE_SZ 16U +#define WS_EXTINFO_EXTENSION_COUNT 1 +static const char cannedNoneNames[] = "none"; +static const char serverSigAlgsName[] = "server-sig-algs"; + +typedef struct { byte *kexInit; word32 kexInitSz; } HandshakeInfo; +typedef struct { + WOLFSSH_CTX *ctx; + const char *algoListKex, *algoListKey, *algoListCipher, *algoListMac; + const char *algoListKeyAccepted; + int isKeying; + HandshakeInfo *handshake; + void *rng; + struct { byte *buffer; word32 length; } outputBuffer; +} WOLFSSH; + +static byte packet[1024], saved_kex[1024]; +static HandshakeInfo handshake; +static word32 planned; +static unsigned sends, allocations, frees, purges; +static int pool_in_use, allocation_fail, prepare_error, send_error; + +static void *bounded_alloc(size_t size) +{ + assert(size <= sizeof(saved_kex)); + if (allocation_fail) return NULL; + assert(!pool_in_use); + pool_in_use = 1; + ++allocations; + return saved_kex; +} +static void bounded_free(void *ptr) +{ + assert(ptr == saved_kex && pool_in_use); + pool_in_use = 0; + ++frees; +} +static HandshakeInfo *HandshakeInfoNew(void *heap) +{ + (void)heap; + assert(handshake.kexInit == NULL); + return &handshake; +} +static void c32toa(word32 value, byte *out) +{ + out[0] = (byte)(value >> 24); + out[1] = (byte)(value >> 16); + out[2] = (byte)(value >> 8); + out[3] = (byte)value; +} +static int wc_RNG_GenerateBlock(void *rng, byte *out, word32 size) +{ + (void)rng; + assert(size == COOKIE_SZ); + memset(out, 0x5a, size); + return WS_SUCCESS; +} +static int PreparePacket(WOLFSSH *ssh, word32 payload_size) +{ + if (prepare_error) return prepare_error; + assert(payload_size + 16U <= sizeof(packet)); + memset(packet, 0xa5, sizeof(packet)); + ssh->outputBuffer.buffer = packet; + ssh->outputBuffer.length = 8; + planned = payload_size; + return WS_SUCCESS; +} +static int BundlePacket(WOLFSSH *ssh) +{ + assert(ssh->outputBuffer.length == planned + 8U); + for (unsigned i = 0; i < 8; ++i) assert(packet[i] == 0xa5); + for (size_t i = ssh->outputBuffer.length; i < sizeof(packet); ++i) + assert(packet[i] == 0xa5); + return WS_SUCCESS; +} +static int wolfSSH_SendPacket(WOLFSSH *ssh) +{ + (void)ssh; + ++sends; + return send_error; +} +static void PurgePacket(WOLFSSH *ssh) +{ + if (ssh != NULL) ssh->outputBuffer.length = 0; + ++purges; +} + +#include "vendor_actual.c" + +static word32 take_u32(const byte *data, size_t length, size_t *offset) +{ + assert(*offset <= length && length - *offset >= 4); + const byte *p = data + *offset; + *offset += 4; + return ((word32)p[0] << 24) | ((word32)p[1] << 16) | + ((word32)p[2] << 8) | p[3]; +} +static void expect_name(const byte *data, size_t length, size_t *offset, + const char *expected) +{ + word32 size = take_u32(data, length, offset); + assert(size == strlen(expected)); + assert(*offset <= length && size <= length - *offset); + assert(memcmp(data + *offset, expected, size) == 0); + *offset += size; +} +static void check_kex(const WOLFSSH *ssh) +{ + const byte *p = packet + 8; + size_t length = ssh->outputBuffer.length - 8U, offset = 1U + COOKIE_SZ; + assert(p[0] == MSGID_KEXINIT); + for (unsigned i = 1; i <= COOKIE_SZ; ++i) assert(p[i] == 0x5a); + expect_name(p, length, &offset, "curve25519-sha256,ecdh-sha2-nistp256"); + expect_name(p, length, &offset, "ecdsa-sha2-nistp256"); + /* Decode independently: both c2s and s2c must be exact, with no default + * CBC/CTR, AES192, extra KEX, or extra MAC fallback appended. */ + for (unsigned direction = 0; direction < 2; ++direction) + expect_name(p, length, &offset, + "aes128-gcm@openssh.com,aes256-gcm@openssh.com"); + for (unsigned direction = 0; direction < 2; ++direction) + expect_name(p, length, &offset, "hmac-sha2-256"); + expect_name(p, length, &offset, "none"); + expect_name(p, length, &offset, "none"); + expect_name(p, length, &offset, ""); + expect_name(p, length, &offset, ""); + assert(offset < length && p[offset++] == 0); + assert(take_u32(p, length, &offset) == 0); + assert(offset == length); + assert(ssh->handshake->kexInitSz == length + 4); + offset = 0; + assert(take_u32(saved_kex, sizeof(saved_kex), &offset) == length); + assert(memcmp(saved_kex + 4, p, length) == 0); +} +static void check_names(void) +{ + static const struct { const char *name; byte id, type; } required[] = { + {"curve25519-sha256", ID_CURVE25519_SHA256, TYPE_KEX}, + {"ecdh-sha2-nistp256", ID_ECDH_SHA2_NISTP256, TYPE_KEX}, + {"ecdsa-sha2-nistp256", ID_ECDSA_SHA2_NISTP256, TYPE_KEY}, + {"aes128-gcm@openssh.com", ID_AES128_GCM, TYPE_CIPHER}, + {"aes256-gcm@openssh.com", ID_AES256_GCM, TYPE_CIPHER}, + {"hmac-sha2-256", ID_HMAC_SHA2_256, TYPE_MAC}, + {"ssh-ed25519", ID_ED25519, TYPE_KEY}, + }; + for (size_t i = 0; i < sizeof(required) / sizeof(required[0]); ++i) { + assert(NameToId(required[i].name, (word32)strlen(required[i].name)) == + required[i].id); + assert(strcmp(IdToName(required[i].id), required[i].name) == 0); + unsigned matches = 0; + for (size_t j = 0; j < sizeof(NameIdMap) / sizeof(NameIdMap[0]); ++j) + if (NameIdMap[j].id == required[i].id) { + assert(NameIdMap[j].type == required[i].type); + ++matches; + } + assert(matches == 1); + } + assert(NameToId("not-an-algorithm", 16) == ID_UNKNOWN); +} +int main(void) +{ + check_names(); + WOLFSSH_CTX ctx = {.side = WOLFSSH_ENDPOINT_SERVER, .privateKeyCount = 1}; + assert(ssh_protocol_policy_apply(NULL) == WS_SSH_CTX_NULL_E); + int (*const setters[])(WOLFSSH_CTX *, const char *) = { + wolfSSH_CTX_SetAlgoListKex, wolfSSH_CTX_SetAlgoListKey, + wolfSSH_CTX_SetAlgoListCipher, wolfSSH_CTX_SetAlgoListMac, + wolfSSH_CTX_SetAlgoListKeyAccepted, + }; + for (size_t i = 0; i < sizeof(setters) / sizeof(setters[0]); ++i) { + assert(setters[i](NULL, "anything") == WS_SSH_CTX_NULL_E); + assert(setters[i](&ctx, "not-an-algorithm") == WS_SUCCESS); + assert(setters[i](&ctx, "") == WS_SUCCESS); + assert(setters[i](&ctx, NULL) == WS_SUCCESS); + } + assert(ssh_protocol_policy_apply(&ctx) == WS_SUCCESS); + /* Real SshInit's pointer assignments are extracted, but the rest of its + * allocation/crypto setup is deliberately not modeled. */ + WOLFSSH session = {.ctx = &ctx}; + WOLFSSH *ssh = &session; + WOLFSSH_CTX *context = &ctx; + (void)context; +#include "session_lists.inc" + assert(ssh->algoListKeyAccepted == ctx.algoListKeyAccepted); + assert(SendKexInit(ssh) == WS_SUCCESS); + check_kex(ssh); + assert(sends == 1 && allocations == 1 && frees == 0); + assert(SendKexInit(ssh) == WS_SUCCESS); + check_kex(ssh); + assert(sends == 2 && allocations == 2 && frees == 1); + assert(SendExtInfo(ssh) == WS_SUCCESS); + size_t offset = 1, length = ssh->outputBuffer.length - 8U; + const byte *p = packet + 8; + assert(p[0] == MSGID_EXT_INFO); + assert(take_u32(p, length, &offset) == 1); + expect_name(p, length, &offset, "server-sig-algs"); + expect_name(p, length, &offset, "ssh-ed25519,ecdsa-sha2-nistp256"); + assert(offset == length); + /* No key and injected packet/allocation failures must not send a fallback. */ + unsigned before = sends; + ctx.privateKeyCount = 0; + assert(SendKexInit(ssh) == WS_BAD_ARGUMENT); + assert(sends == before); + ctx.privateKeyCount = 1; + prepare_error = WS_BUFFER_E; + assert(SendKexInit(ssh) == WS_BUFFER_E); + assert(sends == before); + prepare_error = 0; + allocation_fail = 1; + assert(SendKexInit(ssh) == WS_MEMORY_E); + assert(sends == before && !pool_in_use); + allocation_fail = 0; + send_error = WS_WANT_WRITE; + unsigned old_purges = purges; + assert(SendKexInit(ssh) == WS_WANT_WRITE); + assert(purges == old_purges); + check_kex(ssh); + bounded_free(handshake.kexInit); + puts("PASS: resolved vendor name/ID/type map, actual setters, initial/rekey KEXINIT both directions, server-sig-algs, bounded failure paths"); + return 0; +} diff --git a/tests/web_cookie_auth/admission_test.c b/tests/web_cookie_auth/admission_test.c new file mode 100644 index 0000000..47cf877 --- /dev/null +++ b/tests/web_cookie_auth/admission_test.c @@ -0,0 +1,173 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Included by test.c: public API checks with live-buffer IO/parser/KDF probes. */ +static const char auth_wrong_body[] = "{\"username\":\"alice\",\"password\":\"wrong\"}"; +static const char auth_bad_json[] = "{\"username\":\"alice\",\"password\":\"secret-value\",\"extra\":1}"; + +static web_cookie_auth_snapshot_t auth_counts(void) { + web_cookie_auth_snapshot_t counts; + web_cookie_auth_get_snapshot(&counts); + return counts; +} + +static esp_err_t auth_observe(const char *status) { + watch_login = true; + esp_err_t result = web_cookie_auth_handler(&req); + watch_login = false; /* captured stack pointers are no longer live */ + assert(!strcmp(response_status, status)); + return result; +} + +static void auth_five_verifications(void) { + char token[65], csrf[65]; + for (unsigned i = 0; i < 5; ++i) { + challenge(token, csrf); login_request(token, csrf, auth_wrong_body); + assert(auth_observe("401 Unauthorized") == ESP_OK); + } +} + +static void auth_restart_hook(void) { + assert(!host_lock_depth); + web_cookie_auth_stop(); + assert(web_cookie_auth_start() == ESP_OK); +} + +static void auth_deadline_hook(void) { now += 3000001; } + +static void auth_budget_tests(void) { + char token[65], csrf[65]; + now = 180000000; auth_reset(); web_cookie_auth_clear_counters(); + auth_five_verifications(); + assert(password_calls == 5 && parser_calls == 5 && auth_counts().login_attempts == 5); + unsigned reads = receive_calls, parses = parser_calls; + const int64_t offsets[] = {0, 58999999, 59999999}; + const char *retries[] = {"60", "2", "1"}; + for (unsigned i = 0; i < 3; ++i) { + now = 180000000 + offsets[i]; + challenge(token, csrf); login_request(token, csrf, good_body); + recv_fail = true; /* an exhausted budget must never reach this failure */ + assert(auth_observe("429 Too Many Requests") == ESP_FAIL); + recv_fail = false; + assert(receive_calls == reads && parser_calls == parses && password_calls == 5); + assert(body_offset == 0 && aux.remaining_len == strlen(good_body)); + assert(!strcmp(retry_value, retries[i])); + assert(cookie_count == 1 && strstr(cookie_values[0], "__Host-sak-prelogin=")); + assert(strstr(cookie_values[0], "Max-Age=0")); + assert(auth_counts().active_challenges == 0 && auth_counts().login_attempts == 5); + zero(scratch, sizeof(scratch)); + login_request(token, csrf, good_body); + assert(auth_observe("403 Forbidden") == ESP_FAIL); /* denied challenge stayed consumed */ + assert(receive_calls == reads && parser_calls == parses && password_calls == 5); + } + now = 240000000; + login_request(token, csrf, good_body); + assert(auth_observe("403 Forbidden") == ESP_FAIL); /* refill cannot resurrect it */ + challenge(token, csrf); login_request(token, csrf, good_body); + assert(auth_observe("200 OK") == ESP_OK); + assert(password_calls == 6 && parser_calls == parses + 1 && receive_calls > reads); + assert(auth_counts().login_attempts == 6 && !retry_value[0]); + + auth_reset(); web_cookie_auth_clear_counters(); auth_five_verifications(); + web_cookie_auth_clear_counters(); + challenge(token, csrf); login_request(token, csrf, good_body); + reads = receive_calls; parses = parser_calls; + assert(auth_observe("429 Too Many Requests") == ESP_FAIL); + assert(!auth_counts().login_attempts && auth_counts().throttled == 1); + assert(receive_calls == reads && parser_calls == parses && password_calls == 5); + auth_restart_hook(); /* existing restart replenishment remains intentional */ + challenge(token, csrf); login_request(token, csrf, good_body); + assert(auth_observe("200 OK") == ESP_OK); + assert(auth_counts().login_attempts == 1 && password_calls == 6); + puts("PASS: exhausted budget before receive/parser/KDF, close without drain, rounded Retry-After/refill boundary, consumed challenge and restart semantics"); +} + +static void auth_malformed_budget_tests(void) { + char token[65], csrf[65]; + now = 360000000; auth_reset(); web_cookie_auth_clear_counters(); + for (unsigned i = 0; i < 7; ++i) { + challenge(token, csrf); login_request(token, csrf, auth_bad_json); + assert(auth_observe("400 Bad Request") == ESP_OK); + assert(body_wipes && credential_wipes); + assert(!password_calls && !auth_counts().login_attempts); + } + assert(parser_calls == 7 && receive_calls > 7); + now = 365000000; auth_five_verifications(); + assert(password_calls == 5 && auth_counts().login_attempts == 5); + now = 420000000; /* 60 s after malformed probe, only 55 s after first verification */ + challenge(token, csrf); login_request(token, csrf, good_body); + unsigned reads = receive_calls, parses = parser_calls; + assert(auth_observe("429 Too Many Requests") == ESP_FAIL); + assert(!strcmp(retry_value, "5") && receive_calls == reads && parser_calls == parses); + now = 425000000; + challenge(token, csrf); login_request(token, csrf, good_body); + assert(auth_observe("200 OK") == ESP_OK && password_calls == 6); + puts("PASS: malformed requests do not charge verification attempts or advance the verification window"); +} + +static void auth_epoch_tests(void) { + char token[65], csrf[65]; + for (unsigned phase = 0; phase < 2; ++phase) { + for (unsigned restart = 0; restart < 2; ++restart) { + auth_reset(); web_cookie_auth_clear_counters(); + challenge(token, csrf); login_request(token, csrf, good_body); + void (*hook)(void) = restart ? auth_restart_hook : web_cookie_auth_stop; + if (phase == 0) header_hook = hook; /* consumed challenge, before first probe */ + else parse_hook = hook; /* early probe passed, before final reservation */ + assert(auth_observe("503 Service Unavailable") == (phase ? ESP_OK : ESP_FAIL)); + assert(!password_calls && !auth_counts().login_attempts && !snapshot().active); + if (phase) { + assert(receive_calls && parser_calls == 1 && body_wipes && credential_wipes); + } else { + assert(!receive_calls && !parser_calls && !body_offset); + assert(aux.remaining_len == strlen(good_body)); + } + } + } + puts("PASS: readiness and epoch fencing at early probe and authoritative post-parse reservation"); +} + +static void auth_plaintext_tests(void) { + char token[65], csrf[65]; + for (unsigned mode = 0; mode < 4; ++mode) { + auth_reset(); web_cookie_auth_clear_counters(); + challenge(token, csrf); + login_request(token, csrf, mode == 1 ? auth_wrong_body : mode == 2 ? auth_bad_json : good_body); + const char *status = mode == 1 ? "401 Unauthorized" : mode == 2 ? "400 Bad Request" : + mode == 3 ? "503 Service Unavailable" : "200 OK"; + send_fail = true; db_fail = mode == 3; + assert(auth_observe(status) == ESP_FAIL); + send_fail = db_fail = false; + assert(body_wipes && credential_wipes && !snapshot().active); + assert(password_calls == (mode == 2 ? 0U : 1U)); + assert(auth_counts().login_attempts == password_calls); + } + for (unsigned mode = 0; mode < 2; ++mode) { + auth_reset(); web_cookie_auth_clear_counters(); + challenge(token, csrf); login_request(token, csrf, good_body); + receive_fragment = 7; + if (mode) recv_hook = auth_deadline_hook; + else recv_fail_after = 7; + send_fail = true; + assert(auth_observe("400 Bad Request") == ESP_FAIL); + send_fail = false; recv_fail_after = 0; + assert(body_offset == 7 && aux.remaining_len == strlen(good_body) - 7); + assert(receive_calls == (mode ? 1U : 2U) && body_wipes); + assert(!parser_calls && !password_calls && !auth_counts().login_attempts); + } + auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); + unsigned before = sends; + server.config.max_resp_headers = 1; /* final Set-Cookie fails after successful KDF */ + watch_login = true; + assert(web_cookie_auth_handler(&req) != ESP_OK); + watch_login = false; + server.config.max_resp_headers = 8; + assert(sends == before && body_wipes && credential_wipes && !snapshot().active); + puts("PASS: raw JSON wiped before KDF, credentials wiped after KDF, pre-send error wiping and send/header/partial-receive failure cleanup"); +} + +static void auth_admission_tests(void) { + auth_budget_tests(); + auth_malformed_budget_tests(); + auth_epoch_tests(); + auth_plaintext_tests(); + auth_reset(); +} diff --git a/tests/web_cookie_auth/run.py b/tests/web_cookie_auth/run.py index 47e2952..59d3ec3 100644 --- a/tests/web_cookie_auth/run.py +++ b/tests/web_cookie_auth/run.py @@ -253,7 +253,9 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory: *(["-DHOST_BROKER"] if broker else []), *(["-DHOST_SSH_SETTINGS"] if ssh_settings else []), *(["-DHOST_LIFECYCLE"] if lifecycle else []), - "-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []), + "-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), + "-Wl,--wrap=web_auth_parse_login,--wrap=secure_wipe,--wrap=httpd_resp_set_hdr", + "-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []), "-o", str(tmp / "test")], check=True, timeout=30) subprocess.run([str(tmp / "test")], check=True, timeout=20) if lifecycle: diff --git a/tests/web_cookie_auth/test.c b/tests/web_cookie_auth/test.c index 3dfb3a5..2bb2537 100644 --- a/tests/web_cookie_auth/test.c +++ b/tests/web_cookie_auth/test.c @@ -9,6 +9,7 @@ #undef user_database_username_valid #endif #include "web_cookie_auth.h" +#include "web_auth_parse.h" #include "web_httpd_adapter.h" #include "esp_httpd_priv.h" #ifdef HOST_ADMIN @@ -32,11 +33,48 @@ static bool send_fail, recv_fail; static size_t receive_fragment = 7; static void (*password_hook)(void); static void (*send_hook)(void); +static void (*header_hook)(void), (*parse_hook)(void), (*recv_hook)(void); +static unsigned receive_calls, parser_calls, body_wipes, credential_wipes; +static size_t recv_fail_after; +static bool watch_login; +static const char *watched_body; +static web_auth_credentials_t *watched_credentials; +static char retry_value[16]; static char response_status[48]; static struct httpd_req_aux aux; static httpd_req_t req; size_t host_read_pending(httpd_req_t *r, char *out, size_t n); +/* Link-time wrappers retain the production parser, wipe, and IDF header setter. */ +bool __real_web_auth_parse_login(const char *, size_t, web_auth_credentials_t *); +void __real_secure_wipe(void *, size_t); +esp_err_t __real_httpd_resp_set_hdr(httpd_req_t *, const char *, const char *); +bool __wrap_web_auth_parse_login(const char *body, size_t length, web_auth_credentials_t *credentials) { + assert(!host_lock_depth); ++parser_calls; + if (watch_login) { watched_body = body; watched_credentials = credentials; } + bool valid = __real_web_auth_parse_login(body, length, credentials); + if (parse_hook) { void (*hook)(void) = parse_hook; parse_hook = NULL; hook(); } + return valid; +} +void __wrap_secure_wipe(void *data, size_t length) { + __real_secure_wipe(data, length); + if (watch_login && data == watched_body && length == WEB_AUTH_LOGIN_BODY_MAX + 1) { + zero(data, length); ++body_wipes; + } + if (watch_login && data == watched_credentials && length == sizeof(*watched_credentials)) { + zero(data, length); ++credential_wipes; + } +} +esp_err_t __wrap_httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value) { + assert(!host_lock_depth); + esp_err_t error = __real_httpd_resp_set_hdr(r, name, value); + if (header_hook && !strcmp(name, "Set-Cookie") && + strstr(value, "__Host-sak-prelogin=") && strstr(value, "Max-Age=0")) { + void (*hook)(void) = header_hook; header_hook = NULL; hook(); + } + return error; +} + esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *status) { (void)r; if (fail_header && ++setter_calls == fail_header) return ESP_FAIL; snprintf(response_status, sizeof(response_status), "%s", status); return ESP_OK; @@ -45,11 +83,18 @@ esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *type) { (void)r; (void)type; return ESP_OK; } esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) { - (void)r; ++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body); + (void)r; assert(!host_lock_depth); + if (watch_login) { + if (watched_body) { assert(body_wipes); zero(watched_body, WEB_AUTH_LOGIN_BODY_MAX + 1); } + if (watched_credentials) { assert(credential_wipes); zero(watched_credentials, sizeof(*watched_credentials)); } + } + ++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body); cookie_count = 0; for (unsigned i = 0; i < aux.resp_hdrs_count; ++i) { assert(response_headers[i].value); assert(strcmp(response_headers[i].field, "WWW-Authenticate")); + if (!strcmp(response_headers[i].field, "Retry-After")) + snprintf(retry_value, sizeof(retry_value), "%s", response_headers[i].value); if (!strcmp(response_headers[i].field, "Set-Cookie")) { assert(cookie_count < 2); snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value); @@ -59,10 +104,14 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) { return send_fail ? ESP_FAIL : ESP_OK; } int httpd_req_recv(httpd_req_t *r, char *out, size_t size) { - (void)r; if (recv_fail) return -1; + (void)r; assert(!host_lock_depth); ++receive_calls; + if (watch_login && !body_offset) watched_body = out; + if (recv_fail || (recv_fail_after && body_offset >= recv_fail_after)) return -1; if (size > receive_fragment) size = receive_fragment; memcpy(out, request_body + body_offset, size); body_offset += size; - aux.remaining_len -= size; return (int)size; + aux.remaining_len -= size; + if (recv_hook) { void (*hook)(void) = recv_hook; recv_hook = NULL; hook(); } + return (int)size; } esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); } esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) { @@ -78,6 +127,12 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol esp_err_t user_database_authenticate_password(const uint8_t *u, size_t un, const uint8_t *p, size_t pn, user_principal_t *principal, bool *authenticated) { assert(!host_lock_depth); ++password_calls; + if (watch_login) { + assert(watched_body && watched_credentials && body_wipes && !credential_wipes); + zero(watched_body, WEB_AUTH_LOGIN_BODY_MAX + 1); + assert(u == watched_credentials->username && p == watched_credentials->password); + assert(un == watched_credentials->username_length && pn == watched_credentials->password_length); + } if (password_hook) { void (*hook)(void) = password_hook; password_hook = NULL; hook(); } *authenticated = un == 5 && !memcmp(u, "alice", 5) && pn == 12 && !memcmp(p, "password1234", 12); if (*authenticated) *principal = alice; @@ -93,7 +148,9 @@ static void begin(const char *uri, int method, const char *body) { .content_len = body ? strlen(body) : 0}; aux.remaining_len = req.content_len; request_body = body; body_offset = 0; - response_status[0] = output[0] = 0; cookie_count = 0; + response_status[0] = output[0] = retry_value[0] = 0; cookie_count = 0; + watch_login = false; watched_body = NULL; watched_credentials = NULL; + body_wipes = credential_wipes = 0; } static void add(const char *key, const char *value) { char *at = scratch; @@ -126,10 +183,15 @@ static void login_request(const char *token, const char *csrf, const char *body) char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies); } static void auth_reset(void) { + watch_login = false; watched_body = NULL; watched_credentials = NULL; + header_hook = parse_hook = recv_hook = NULL; recv_fail_after = 0; + receive_calls = parser_calls = 0; web_cookie_auth_stop(); reset(); assert(web_cookie_auth_start() == ESP_OK); password_calls = 0; password_hook = NULL; } +#include "admission_test.c" + #ifdef HOST_ADMIN #include "admin_test.c" #endif @@ -302,6 +364,7 @@ int main(void) { server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2); server.config.max_resp_headers = 8; puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login"); + auth_admission_tests(); #ifdef HOST_ADMIN admin_tests(); #endif diff --git a/tests/wolfssh_auth_contract/README.md b/tests/wolfssh_auth_contract/README.md index a4d9dcd..635c7a9 100644 --- a/tests/wolfssh_auth_contract/README.md +++ b/tests/wolfssh_auth_contract/README.md @@ -14,8 +14,9 @@ preprocess and execution subprocesses have 30/30/10-second limits. The runner prefers the sole `.pio/build/*/compile_commands.json`, otherwise the root database. Select another existing database with `--compile-commands PATH`. -It preprocesses the actual wolfSSH `internal.c` compile command (`-E -dM`) and -checks this reviewed profile: +It requires the actual generated wolfSSH `internal.c` compilation input to equal +`tools/security_overrides.py`'s rendering of the pinned original, preprocesses +that compile command (`-E -dM`), and checks this reviewed profile: - `LIBWOLFSSH_VERSION_HEX == 0x01004020` (1.4.20). - RSA disabled; ECDSA and Ed25519 not disabled. @@ -23,8 +24,8 @@ checks this reviewed profile: For hosts without the ESP compiler/database, explicitly use `--host-only`. This prints a **SKIP** for production feature verification; it still checks the -source/version/pin and executes the host contract with the reviewed feature -profile. A stale compilation database is not proof of the next firmware build's +source/version/pin and executes the rendered host contract with the reviewed +feature profile. A stale compilation database is not proof of the next firmware build's configuration. ## What executes @@ -39,8 +40,9 @@ reviewed SHA-256 of `managed_components/wolfssl__wolfssh/src/internal.c`: Any same-version source change fails before compilation. **Re-audit before updating this hash**; do not automatically bless a dependency update. -The runner extracts actual function definitions by balancing braces after -masking comments/string literals. It does not rewrite their bodies: +The runner extracts actual **overridden production** function definitions by +balancing braces after masking comments/string literals. Extraction does not +rewrite their bodies; the separately verified build overlay does: - `GetBoolean`, `GetUint32`, `GetSize`, `GetStringRef` - `DoUserAuthRequestPassword`, `DoUserAuthRequestPublicKey`, `DoUserAuthRequest` @@ -53,7 +55,7 @@ models, name/algorithm lookup, crypto and packet-output doubles. Binary request fixtures execute the extracted parsers; ordered event traces assert callback, hashing/signature and response order, rather than inspecting source substrings. -The 35 cases cover: +The 35 baseline cases cover (with the stricter malformed-password contract): - Ed25519 and ECDSA: signed authorization rejection (`INVALID_PUBLICKEY`, `FAILURE`, `REJECTED`, `INVALID_USER`, `INVALID_AUTHTYPE`) never hashes, @@ -62,8 +64,8 @@ The 35 cases cover: probe sends PK_OK but does not complete authentication. - Bad signatures, good signatures, success-result veto, ignored failure-result callback return, and auth `WOULD_BLOCK`. -- Password success/failure, rejected password change, and the installed parser's - callback on a truncated new-password-length field. No password result callback. +- Password success/failure, rejected password change, and rejection **before the + callback** for a truncated new-password-length field. No password result callback. - Disabled `none`, unknown methods/key algorithms and truncated signed framing. - Direct keyboard-interactive dispatch invokes a **registered non-NULL rejecting prompt callback**, returns error and purges without preparing/building/sending @@ -76,6 +78,15 @@ The 35 cases cover: prefix leaves the library copy intact. A blocked flush of earlier data returns a negative code without consuming new data. +A further **100 generated-parser cases** test short/missing flags and lengths, +truncated/oversized/`UINT32_MAX` password and replacement-password lengths, +checked initial offsets and canaries, no callback on malformed fields, preserved +username/service/method prefixes, and suffix wiping before response emission. +They include success, invalid/backend/rejected outcomes, password changes, no +callback, callback-modified credential pointers/lengths, and pending retry. +`WS_AUTH_PENDING` deliberately preserves bytes; the project's synchronous +callbacks do not use it. This is not an unconditional async secret-wipe promise. + ## Limits / ownership This is a library parser/control-flow regression, **not application callback diff --git a/tests/wolfssh_auth_contract/contract.c b/tests/wolfssh_auth_contract/contract.c index 1068853..f93a643 100644 --- a/tests/wolfssh_auth_contract/contract.c +++ b/tests/wolfssh_auth_contract/contract.c @@ -67,7 +67,8 @@ static const word32 cannedKeyAlgoClientSz = sizeof(cannedKeyAlgoClient); static char events[64]; static unsigned event_count, groups; static int auth_return, crypto_return, result_return, send_return; -static int expect_new_password; +static int expect_new_password, poison_password_pointers; +static void inspect_password_wipe(void); static void event(char c) { assert(event_count + 1 < sizeof(events)); events[event_count++] = c; } static void ato32(const byte *b, word32 *v) { *v = (word32)b[0] << 24 | (word32)b[1] << 16 | (word32)b[2] << 8 | b[3]; @@ -89,8 +90,13 @@ static byte MatchIdLists(int side, const byte *id, word32 count, const byte *lis for (word32 i = 0; i < n; ++i) if (*id == list[i]) return *id; return ID_UNKNOWN; } -static int wolfSSH_SetUsernameRaw(WOLFSSH *s, const byte *u, word32 n) { return WS_SUCCESS; } -static int SendUserAuthFailure(WOLFSSH *s, byte partial) { event('F'); return WS_SUCCESS; } +static int wolfSSH_SetUsernameRaw(WOLFSSH *s, const byte *u, word32 n) { + /* Called again after the method parser: prefix must still be valid. */ + assert(n == 4 && !memcmp(u, "test", 4)); return WS_SUCCESS; +} +static int SendUserAuthFailure(WOLFSSH *s, byte partial) { + inspect_password_wipe(); event('F'); return send_return; +} static int SendUserAuthPkOk(WOLFSSH *s, const byte *a, word32 an, const byte *k, word32 kn) { event('P'); return WS_SUCCESS; } @@ -129,6 +135,17 @@ static int authorize(byte method, WS_UserAuthData *a, void *ctx) { assert(a->sf.password.hasNewPassword == expect_new_password); assert(a->sf.password.passwordSz == 5); assert(!memcmp(a->sf.password.password, "dummy", 5)); + if (expect_new_password) { + assert(a->sf.password.newPasswordSz == 9); + assert(!memcmp(a->sf.password.newPassword, "new-dummy", 9)); + } + if (poison_password_pointers) { + /* Cleanup must use checked packet bounds, not mutable authData. */ + a->sf.password.password = (const byte *)(uintptr_t)1; + a->sf.password.passwordSz = UINT32_MAX; + a->sf.password.newPassword = (const byte *)(uintptr_t)1; + a->sf.password.newPasswordSz = UINT32_MAX; + } } return auth_return; } @@ -144,8 +161,23 @@ static int reject_keyboard(WS_UserAuthData_Keyboard *k, void *ctx) { static int allowed(WOLFSSH *s, void *ctx) { return WOLFSSH_USERAUTH_PASSWORD | WOLFSSH_USERAUTH_PUBLICKEY; } -static byte output[1024], packet[1024]; -static word32 length; +static byte output[1024]; +static struct { byte before[16], bytes[1024], after[16]; } storage, saved; +#define packet storage.bytes +static word32 length, suffix_start, suffix_end; +static int watch_password; +static void inspect_password_wipe(void) { + if (!watch_password) return; + assert(!memcmp(storage.before, saved.before, sizeof(storage.before))); + assert(!memcmp(storage.after, saved.after, sizeof(storage.after))); + assert(!memcmp(packet, saved.bytes, suffix_start)); + for (word32 i = suffix_start; i < suffix_end; ++i) assert(packet[i] == 0); + assert(!memcmp(packet + suffix_end, saved.bytes + suffix_end, + sizeof(packet) - suffix_end)); +} +static void watch_suffix(word32 start) { + suffix_start = start; suffix_end = length; saved = storage; watch_password = 1; +} static WOLFSSH_CTX context = { authorize, result, reject_keyboard, allowed }; static WOLFSSH ssh; static void reset(void) { @@ -154,7 +186,9 @@ static void reset(void) { ssh.ctx = &context; ssh.outputBuffer.buffer = output; ssh.sessionIdSz = 32; auth_return = WOLFSSH_USERAUTH_SUCCESS; crypto_return = WS_SUCCESS; result_return = WS_SUCCESS; send_return = WS_SUCCESS; expect_new_password = 0; - length = 0; + length = 0; watch_password = 0; poison_password_pointers = 0; + memset(&storage, 0xa5, sizeof(storage)); + context.userAuthCb = authorize; } static void blob(const void *s, word32 n) { assert(length + 4 + n <= sizeof(packet)); @@ -178,6 +212,95 @@ static void check(const char *trace, int done) { assert(!strcmp(events, trace)); assert((ssh.clientState == CLIENT_USERAUTH_DONE) == done); ++groups; } +static void password_cleanup_tests(void) { + /* Every truncation of both encodings, including flag and length fields. + * No malformed input may reach the auth/database double, even if absent. */ + for (int change = 0; change < 2; ++change) { + word32 payload_size = change ? 23 : 10; + for (word32 cut = 0; cut < payload_size; ++cut) { + for (int no_callback = 0; no_callback < 2; ++no_callback) { + reset(); request("password"); word32 start = length; + packet[length++] = change; string("dummy"); + if (change) string("new-dummy"); + length = start + cut; watch_suffix(start); + if (no_callback) context.userAuthCb = NULL; + assert(dispatch() == WS_BUFFER_E); check("", 0); + inspect_password_wipe(); + } + } + } + const word32 oversized[] = { 6, 1024, UINT32_MAX }; + for (unsigned i = 0; i < sizeof(oversized)/sizeof(oversized[0]); ++i) { + for (int change = 0; change < 2; ++change) { + reset(); request("password"); word32 start = length; + packet[length++] = change; string("dummy"); + word32 field = start + 1; + if (change) { field = length; string("new-dummy"); } + c32toa(change && oversized[i] == 6 ? 10 : oversized[i], packet + field); + watch_suffix(start); + assert(dispatch() == WS_BUFFER_E); check("", 0); inspect_password_wipe(); + } + } + /* Application bad-password/backend failure/admission denial all retain the + * library's ordinary result mapping. Include partial success and no callback. */ + const int outcomes[] = { WOLFSSH_USERAUTH_SUCCESS, WOLFSSH_USERAUTH_INVALID_PASSWORD, + WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_REJECTED, + WOLFSSH_USERAUTH_INVALID_USER, WOLFSSH_USERAUTH_INVALID_AUTHTYPE, + WOLFSSH_USERAUTH_PARTIAL_SUCCESS }; + for (unsigned i = 0; i < sizeof(outcomes)/sizeof(outcomes[0]); ++i) { + for (int change = 0; change < 2; ++change) { + reset(); request("password"); word32 start = length; + packet[length++] = change; string("dummy"); + if (change) string("new-dummy"); + expect_new_password = change; auth_return = outcomes[i]; + poison_password_pointers = 1; + /* Trailing payload is also wiped but not included in parsed idx. */ + word32 parsed_end = length; packet[length++] = 0x71; + watch_suffix(start); + WS_UserAuthData data = {0}; data.username = packet + 4; data.usernameSz = 4; + word32 idx = start; + assert(DoUserAuthRequestPassword(&ssh, &data, packet, length, &idx) == WS_SUCCESS); + assert(idx == (outcomes[i] == WOLFSSH_USERAUTH_REJECTED ? start : parsed_end)); + check(i == 0 ? "A" : "AF", i == 0); inspect_password_wipe(); + } + } + reset(); request("password"); word32 start = length; + packet[length++] = 0; string("dummy"); watch_suffix(start); + context.userAuthCb = NULL; + assert(dispatch() == WS_SUCCESS); check("F", 0); inspect_password_wipe(); + + reset(); request("password"); start = length; + packet[length++] = 0; string("dummy"); watch_suffix(start); + auth_return = WOLFSSH_USERAUTH_FAILURE; send_return = WS_WANT_WRITE; + assert(dispatch() == WS_WANT_WRITE); check("AF", 0); inspect_password_wipe(); + + for (int change = 0; change < 2; ++change) { + reset(); request("password"); start = length; + packet[length++] = change; string("dummy"); + if (change) string("new-dummy"); + expect_new_password = change; watch_suffix(start); + auth_return = WOLFSSH_USERAUTH_WOULD_BLOCK; + assert(dispatch() == WS_AUTH_PENDING); check("A", 0); + assert(!memcmp(&storage, &saved, sizeof(storage))); + auth_return = WOLFSSH_USERAUTH_SUCCESS; + assert(dispatch() == WS_SUCCESS); check("AA", 1); inspect_password_wipe(); + } + /* Invalid argument paths must neither dereference idx nor guess wipe bounds. */ + for (int bad = 0; bad < 8; ++bad) { + reset(); request("password"); start = length; + packet[length++] = 0; string("dummy"); saved = storage; + WS_UserAuthData data = {0}; word32 idx = start; + if (bad == 5) idx = length + 1; + if (bad == 6) idx = UINT32_MAX; + if (bad == 7) ssh.ctx = NULL; + int ret = DoUserAuthRequestPassword(bad == 0 ? NULL : &ssh, + bad == 1 ? NULL : &data, bad == 2 ? NULL : packet, + bad == 3 ? 0 : length, bad == 4 ? NULL : &idx); + assert(ret == (bad == 5 || bad == 6 ? WS_BUFFER_E : WS_BAD_ARGUMENT)); + assert(!memcmp(&storage, &saved, sizeof(storage))); check("", 0); + } +} + int main(void) { const int rejected[] = { WOLFSSH_USERAUTH_INVALID_PUBLICKEY, WOLFSSH_USERAUTH_FAILURE, WOLFSSH_USERAUTH_REJECTED, @@ -209,10 +332,10 @@ int main(void) { reset(); request("password"); packet[length++] = 1; string("dummy"); string("new-dummy"); expect_new_password = 1; auth_return = WOLFSSH_USERAUTH_INVALID_AUTHTYPE; assert(dispatch() == WS_SUCCESS); check("AF", 0); - /* Actual parser still calls auth when the new-password length is truncated. */ + /* The generated parser rejects a truncated new-password length before auth. */ reset(); request("password"); packet[length++] = 1; string("dummy"); expect_new_password = 1; auth_return = WOLFSSH_USERAUTH_INVALID_AUTHTYPE; - assert(dispatch() == WS_SUCCESS); check("AF", 0); + assert(dispatch() == WS_BUFFER_E); check("", 0); const char *unsupported[] = { "none", "unrecognized" }; for (unsigned i = 0; i < 2; ++i) { reset(); request(unsupported[i]); assert(dispatch() == WS_SUCCESS); check("F", 0); @@ -241,6 +364,9 @@ int main(void) { reset(); ssh.outputBuffer.plainSz = 2; send_return = WS_WANT_WRITE; byte data[] = { 1, 2 }; assert(SendChannelData(&ssh, 1, data, 2) == WS_WANT_WRITE); assert(output[9] == 0); check("S", 0); - printf("PASS: %u actual wolfSSH parser/control-flow cases\n", groups); + assert(groups == 35); + printf("PASS: %u original wolfSSH parser/control-flow cases (stricter malformed password contract)\n", groups); + password_cleanup_tests(); + printf("PASS: %u additional generated password parser/cleanup cases\n", groups - 35); return 0; } diff --git a/tests/wolfssh_auth_contract/run.py b/tests/wolfssh_auth_contract/run.py index f67803f..20cb9b1 100644 --- a/tests/wolfssh_auth_contract/run.py +++ b/tests/wolfssh_auth_contract/run.py @@ -8,12 +8,16 @@ from pathlib import Path import re import shlex import subprocess +import sys import tempfile ROOT = Path(__file__).resolve().parents[2] HERE = Path(__file__).resolve().parent VENDOR = ROOT / "managed_components/wolfssl__wolfssh" REVIEWED_SHA256 = "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9" +sys.dont_write_bytecode = True +sys.path.insert(0, str(ROOT / "tools")) +from security_overrides import ENTRIES, render_entry def extract(source, name): @@ -34,10 +38,24 @@ def extract(source, name): return source[start:end] + "\n" -def check_build_profile(database): +def check_build_profile(database, override, expected): entries = json.loads(database.read_text()) - entry = next(e for e in entries if Path(e["file"]).resolve() == - (VENDOR / "src/internal.c").resolve()) + + def source_path(entry): + path = Path(entry["file"]) + return (Path(entry["directory"]) / path).resolve() + + original = (VENDOR / "src/internal.c").resolve() + if any(source_path(e) == original for e in entries): + raise RuntimeError("Production still compiles original internal.c; reconfigure the build") + suffix = ("security_overrides", override.name, "internal.c") + matches = [e for e in entries if source_path(e).parts[-3:] == suffix] + if len(matches) != 1: + raise RuntimeError(f"Expected one generated wolfSSH compile entry, found {len(matches)}") + entry = matches[0] + actual = source_path(entry).read_bytes() + if actual != expected: + raise RuntimeError("Generated wolfSSH source differs from render_entry; reconfigure the build") args = entry.get("arguments") or shlex.split(entry["command"]) # Strip output/dependency-writing flags: this must only preprocess to stdout. clean = [] @@ -59,7 +77,8 @@ def check_build_profile(database): "WOLFSSH_NO_ED25519", "NO_FAILURE_ON_REJECTED") if "WOLFSSH_NO_RSA" not in macros or any(m in macros for m in absent): raise RuntimeError("Resolved wolfSSH auth feature profile changed; re-audit") - print("PASS: actual compiler preprocessing matches reviewed auth feature profile", flush=True) + print("PASS: generated source equals render_entry; actual compiler preprocessing matches reviewed auth feature profile", flush=True) + return actual.decode() def main(): @@ -83,12 +102,17 @@ def main(): if not re.search(r'^\s*wolfssl/wolfssh:\s*"1\.4\.20"\s*$', manifest, re.M): raise RuntimeError("Application must pin wolfSSH exactly to 1.4.20") + overrides = [e for e in ENTRIES if e.component == "wolfssl__wolfssh" and + e.source == "managed_components/wolfssl__wolfssh/src/internal.c"] + if len(overrides) != 1 or overrides[0].root != "project" or overrides[0].sha256 != REVIEWED_SHA256: + raise RuntimeError("Expected one independently pinned project wolfSSH override") + override = overrides[0] + _, expected = render_entry(override, {"project": ROOT}) if options.host_only: - print("SKIP: production feature verification (--host-only)", flush=True) + print("SKIP: production generated-source/feature verification (--host-only); testing render_entry output", flush=True) + source = expected.decode() else: - check_build_profile(options.compile_commands) - - source = raw.decode() + source = check_build_profile(options.compile_commands, override, expected) # Use the installed public callback data layouts, not hand-maintained copies. header = (VENDOR / "wolfssh/ssh.h").read_text() types = header[header.index("typedef struct WS_UserAuthData_Password {"): @@ -101,14 +125,19 @@ def main(): "DoUserAuthRequestPassword", "DoUserAuthRequestPublicKey", "SendUserAuthKeyboardRequest", "DoUserAuthRequest", "GetAllowedAuth", "SendChannelData"] - extracted = "\n".join(extract(source, name) for name in names) + # Exercise the installed nonoptimizable wipe, not a memset replacement. + misc = (VENDOR / "src/misc.c").read_text() + wipe = extract(misc.replace("STATIC INLINE void ForceZero", "static void ForceZero"), "ForceZero") + if "volatile byte*" not in wipe: + raise RuntimeError("ForceZero implementation changed; re-audit") + extracted = wipe + "\n" + "\n".join(extract(source, name) for name in names) with tempfile.TemporaryDirectory(prefix="wolfssh-auth-contract-") as temp: temp = Path(temp) (temp / "auth_types.h").write_text(types) (temp / "actual.c").write_text(extracted) binary = temp / "contract" cc = shlex.split(os.environ.get("CC", "cc")) - subprocess.run(cc + ["-std=c99", "-Wall", "-Wextra", "-Werror", + subprocess.run(cc + ["-std=c99", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-I", str(temp), str(HERE / "contract.c"), "-o", str(binary)], check=True, timeout=30, env={**os.environ, "CCACHE_DISABLE": "1"}) diff --git a/tools/security_overrides.py b/tools/security_overrides.py new file mode 100644 index 0000000..865a565 --- /dev/null +++ b/tools/security_overrides.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Generate audited SDK sources in the build tree, never modify dependencies. + +Extend ENTRIES with an independently pinned Entry (root='idf' or 'project'). +Every Edit must match exactly once. CMake consumes the generated manifest and +replaces only the matching component source, retaining its compile properties. +""" +# SPDX-License-Identifier: GPL-3.0-only +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import os +from pathlib import Path +import re +import sys +import tempfile + + +class OverrideError(ValueError): + pass + + +@dataclass(frozen=True) +class Edit: + old: str + new: str + + +@dataclass(frozen=True) +class Entry: + name: str + component: str + root: str + source: str + sha256: str + edits: tuple[Edit, ...] + + +WIPE = """/* Retired secret-bearing storage must not survive allocator reuse. */ +static void security_override_wipe(void *buffer, size_t length) +{ + volatile unsigned char *p = buffer; + while (length--) { + *p++ = 0; + } +} + +""" + +SCRATCH_RESIZE = """/* Keep shrinking as well as growing: no permanent maximum-size allocation. + * Allocation failure preserves the old buffer for normal request cleanup. + * A resize briefly owns both bounded allocations so the retired copy can wipe. */ +static bool security_override_resize_scratch(struct httpd_req_aux *ra, size_t size) +{ + if (size == ra->scratch_cur_size) { + return true; + } + char *replacement = malloc(size); + if (replacement == NULL) { + return false; + } + if (ra->scratch != NULL) { + memcpy(replacement, ra->scratch, MIN(size, ra->scratch_cur_size)); + security_override_wipe(ra->scratch, ra->scratch_cur_size); + free(ra->scratch); + } + ra->scratch = replacement; + ra->scratch_cur_size = size; + return true; +} + +""" + +TLS_GUARDS = """/* The server profile and record-buffer wipe audit require these features. + * Client cipher configuration is deliberately unchanged. */ +#if !defined(MBEDTLS_SSL_PROTO_TLS1_2) || !defined(MBEDTLS_SSL_SRV_C) || \\ + !defined(MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED) || \\ + !defined(MBEDTLS_ECDH_C) || !defined(MBEDTLS_ECDSA_C) || \\ + !defined(MBEDTLS_AES_C) || !defined(MBEDTLS_GCM_C) || \\ + !defined(MBEDTLS_SHA256_C) || !defined(MBEDTLS_SHA384_C) +#error "Security override requires TLS 1.2 ECDHE-ECDSA AES-GCM SHA256/SHA384" +#endif +#if defined(CONFIG_MBEDTLS_DYNAMIC_BUFFER) +#error "Reaudit dynamic TLS buffer destruction before enabling it" +#endif + +""" + +TLS_POLICY = """ /* mbedTLS retains this pointer: it must outlive every server session. */ + static const int security_server_ciphersuites[] = { + MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + 0 + }; + mbedtls_ssl_conf_ciphersuites(&tls->conf, security_server_ciphersuites); + mbedtls_ssl_conf_min_tls_version(&tls->conf, MBEDTLS_SSL_VERSION_TLS1_2); + mbedtls_ssl_conf_max_tls_version(&tls->conf, MBEDTLS_SSL_VERSION_TLS1_2); +#if defined(MBEDTLS_SSL_RENEGOTIATION) + mbedtls_ssl_conf_renegotiation(&tls->conf, MBEDTLS_SSL_RENEGOTIATION_DISABLED); +#endif + +""" + +ENTRIES = ( + Entry("wolfssh_internal", "wolfssl__wolfssh", "project", + "managed_components/wolfssl__wolfssh/src/internal.c", + "81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9", ( + Edit(""" WS_UserAuthData_Password* pw = NULL; + int ret = WS_SUCCESS; +""", """ WS_UserAuthData_Password* pw = NULL; + word32 passwordStart = 0; + int wipePassword = 0; + int ret = WS_SUCCESS; +"""), + Edit(""" WLOG(WS_LOG_DEBUG, "Entering DoUserAuthRequestPassword()"); + + if (ssh == NULL || authData == NULL || + buf == NULL || len == 0 || idx == NULL) { + + ret = WS_BAD_ARGUMENT; + } + + if (ret == WS_SUCCESS) { + begin = *idx; +""", """ WLOG(WS_LOG_DEBUG, "Entering DoUserAuthRequestPassword()"); + + if (ssh == NULL || authData == NULL || + buf == NULL || len == 0 || idx == NULL || ssh->ctx == NULL) { + + ret = WS_BAD_ARGUMENT; + } + + if (ret == WS_SUCCESS && *idx > len) + ret = WS_BUFFER_E; + + if (ret == WS_SUCCESS) { + passwordStart = *idx; + wipePassword = 1; + begin = *idx; +"""), + Edit("ret = GetUint32(&pw->passwordSz, buf, len, &begin);", + "ret = GetSize(&pw->passwordSz, buf, len, &begin);"), + Edit("ret = GetUint32(&pw->newPasswordSz, buf, len, &begin);", + "ret = GetSize(&pw->newPasswordSz, buf, len, &begin);"), + Edit(""" if (ssh->ctx->userAuthCb != NULL) { + WLOG(WS_LOG_DEBUG, "DUARPW: Calling the userauth callback"); +""", """ if (ret == WS_SUCCESS && ssh->ctx->userAuthCb != NULL) { + WLOG(WS_LOG_DEBUG, "DUARPW: Calling the userauth callback"); +"""), + Edit(""" else { + WLOG(WS_LOG_DEBUG, "DUARPW: No user auth callback"); +""", """ else if (ret == WS_SUCCESS) { + WLOG(WS_LOG_DEBUG, "DUARPW: No user auth callback"); +"""), + Edit(""" if (authFailure || partialSuccess) { + ret = SendUserAuthFailure(ssh, partialSuccess); + } + else if (ret == WS_SUCCESS) { + ssh->clientState = CLIENT_USERAUTH_DONE; + } + + WLOG(WS_LOG_DEBUG, "Leaving DoUserAuthRequestPassword(), ret = %d", ret); +""", """ /* Preserve the username/service/method prefix used by the caller. + * Wipe only the checked packet suffix, never callback pointers or declared + * credential sizes. ForceZero is the library's nonoptimizable primitive. + * Async callbacks need this payload for retry: the project never returns + * pending, but this is not a full secret-lifetime guarantee for async users. */ + if (wipePassword && ret != WS_AUTH_PENDING) + ForceZero(buf + passwordStart, len - passwordStart); + + if (authFailure || partialSuccess) { + ret = SendUserAuthFailure(ssh, partialSuccess); + } + else if (ret == WS_SUCCESS) { + ssh->clientState = CLIENT_USERAUTH_DONE; + } + + WLOG(WS_LOG_DEBUG, "Leaving DoUserAuthRequestPassword(), ret = %d", ret); +"""), + )), + Entry("https_server", "esp_https_server", "idf", + "components/esp_https_server/src/https_server.c", + "6481942b62e51125e2a43441fa0900cbda74bd2ea05c82f0c29eb4933c31946e", ( + Edit('const static char *TAG = "esp_https_server";\n', + WIPE + 'const static char *TAG = "esp_https_server";\n'), + Edit(""" if (!transport_ctx) { + esp_https_server_last_error_t last_error = {0}; + last_error.last_error = ESP_ERR_NO_MEM; + http_dispatch_event_to_event_loop(HTTPS_SERVER_EVENT_ERROR, &last_error, sizeof(last_error)); + return ESP_ERR_NO_MEM; + } +""", """ if (!transport_ctx) { + esp_https_server_last_error_t last_error = {0}; + last_error.last_error = ESP_ERR_NO_MEM; + http_dispatch_event_to_event_loop(HTTPS_SERVER_EVENT_ERROR, &last_error, sizeof(last_error)); + esp_tls_server_session_delete(tls); + return ESP_ERR_NO_MEM; + } +"""), + Edit(""" if (cfg->serverkey_buf) { + free((void *)cfg->serverkey_buf); + } +""", """ if (cfg->serverkey_buf) { + security_override_wipe((void *)cfg->serverkey_buf, cfg->serverkey_bytes); + free((void *)cfg->serverkey_buf); + } +"""), + Edit(""" ret = httpd_start(&handle, &config->httpd); + if (ret != ESP_OK) { + free(ssl_ctx); + ssl_ctx = NULL; + return ret; + } +""", """ ret = httpd_start(&handle, &config->httpd); + if (ret != ESP_OK) { + if (ssl_ctx != NULL) { + config->httpd.open_fn = ssl_ctx->open_fn; + free_secure_context(ssl_ctx); + config->httpd.global_transport_ctx = NULL; + config->httpd.global_transport_ctx_free_fn = NULL; + } + return ret; + } +"""), + )), + Entry("httpd_parse", "esp_http_server", "idf", + "components/esp_http_server/src/httpd_parse.c", + "6bba77064aaa68a06f8d4c01432064a1b050c91ed22741c547785b0d8a6c07d8", ( + Edit('static const char *TAG = "httpd_parse";\n', + WIPE + SCRATCH_RESIZE + 'static const char *TAG = "httpd_parse";\n'), + Edit(" size_t at_offset = parser_data->last.at - raux->scratch;\n", + """ /* No parser position exists until a callback sets it. In particular, + * parse_init/init_req_aux start with both pointers NULL. */ + bool has_at = parser_data->last.at != NULL && raux->scratch != NULL; + size_t at_offset = has_at ? parser_data->last.at - raux->scratch : 0; +"""), + Edit(" parser_data->last.at = raux->scratch + at_offset;\n", + " parser_data->last.at = has_at ? raux->scratch + at_offset : NULL;\n"), + Edit(""" raux->scratch = (char*) realloc(raux->scratch, offset + buf_len); + if (raux->scratch == NULL) { +""", """ if (!security_override_resize_scratch(raux, offset + buf_len)) { +"""), + Edit(" raux->scratch_cur_size = offset + buf_len;\n", ""), + Edit(""" free(ra->scratch); + ra->scratch = NULL; +""", """ security_override_wipe(ra->scratch, ra->scratch_cur_size); + free(ra->scratch); + ra->scratch = NULL; +"""), + )), + Entry("esp_tls_mbedtls", "esp-tls", "idf", + "components/esp-tls/esp_tls_mbedtls.c", + "09210c5a601647ca5775d127a2951bab2f3e509192b53487bbea8a93d8731b78", ( + Edit('static const char *TAG = "esp-tls-mbedtls";\n', + TLS_GUARDS + 'static const char *TAG = "esp-tls-mbedtls";\n'), + Edit(" mbedtls_ssl_conf_set_user_data_p(&tls->conf, cfg->userdata);\n", + TLS_POLICY + " mbedtls_ssl_conf_set_user_data_p(&tls->conf, cfg->userdata);\n"), + )), +) + + +def apply_edits(text: str, edits: tuple[Edit, ...]) -> str: + for index, edit in enumerate(edits, 1): + count = text.count(edit.old) if edit.old else 0 + if count != 1: + raise OverrideError(f"edit {index}: expected exactly one match, got {count}") + text = text.replace(edit.old, edit.new, 1) + return text + + +def verify_version(idf: Path) -> Path: + version = idf / "components/esp_common/include/esp_idf_version.h" + text = version.read_text(encoding="utf-8") + for part, expected in (("MAJOR", "5"), ("MINOR", "5"), ("PATCH", "0")): + found = re.findall(r"^#define ESP_IDF_VERSION_" + part + r"\s+(\d+)\s*$", text, re.M) + if found != [expected]: + raise OverrideError(f"requires ESP-IDF 5.5.0: {version} ({part}={found})") + return version + + +def render_entry(entry: Entry, roots: dict[str, Path]) -> tuple[Path, bytes]: + if not all(re.fullmatch(r"[a-zA-Z0-9_-]+", value) for value in (entry.name, entry.component)): + raise OverrideError("invalid entry name/component") + root = roots[entry.root].resolve() + source = (root / entry.source).resolve() + if not source.is_relative_to(root): + raise OverrideError(f"source escapes {entry.root}: {entry.source}") + raw = source.read_bytes() + actual = hashlib.sha256(raw).hexdigest() + if actual != entry.sha256: + raise OverrideError(f"{entry.name}: SHA256 mismatch for {source}: expected {entry.sha256}, got {actual}; reaudit, do not repin blindly") + return source, apply_edits(raw.decode("utf-8"), entry.edits).encode("utf-8") + + +def write_if_changed(path: Path, data: bytes) -> bool: + if path.exists() and path.read_bytes() == data: + return False + path.parent.mkdir(parents=True, exist_ok=True) + name = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as output: + name = output.name + output.write(data) + os.replace(name, path) + finally: + if name and os.path.exists(name): + os.unlink(name) + return True + + +def cmake_quote(value: str) -> str: + # Semicolons/newlines would turn one source into several CMake list entries. + if any(c in value for c in ";\n\r"): + raise OverrideError("unsupported character in CMake path") + return '"' + value.replace("\\", "/").replace('"', '\\"').replace("$", "\\$") + '"' + + +def generate(idf: Path, project: Path, binary: Path, entries: tuple[Entry, ...] = ENTRIES) -> Path: + idf, project, binary = idf.resolve(), project.resolve(), binary.resolve() + if binary.is_relative_to(idf) or binary == project or idf.is_relative_to(binary): + raise OverrideError("binary directory must be separate from SDK and project source root") + version = verify_version(idf) + roots = {"idf": idf, "project": project} + names = [entry.name for entry in entries] + if not names or len(set(names)) != len(names): + raise OverrideError("absent or duplicate override entries") + output = binary / "security_overrides" + rendered = [] + seen = set() + # Validate the entire plan before writing any generated source or manifest. + for entry in entries: + source, data = render_entry(entry, roots) + if source in seen: + raise OverrideError(f"ambiguous duplicate source: {source}") + seen.add(source) + target = output / entry.name / source.name + rendered.append((entry, source, target, data)) + lines = ["# Generated by tools/security_overrides.py; do not edit.", + "set(SAK_SECURITY_OVERRIDE_IDS " + " ".join(names) + ")", + "set(SAK_SECURITY_VERSION_HEADER " + cmake_quote(str(version)) + ")"] + for entry, source, target, _ in rendered: + for key, value in (("COMPONENT", entry.component), ("ORIGINAL", str(source)), + ("GENERATED", str(target)), ("SHA256", entry.sha256)): + lines.append(f"set(SAK_SECURITY_{entry.name}_{key} {cmake_quote(value)})") + manifest = output / "manifest.cmake" + manifest_data = ("\n".join(lines) + "\n").encode("utf-8") + for path in [manifest] + [item[2] for item in rendered]: + resolved = path.resolve() + if not resolved.is_relative_to(binary) or resolved.is_relative_to(idf) or resolved in seen: + raise OverrideError(f"output escapes binary directory or aliases a source: {path}") + for _, _, target, data in rendered: + write_if_changed(target, data) + write_if_changed(manifest, manifest_data) + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--idf-path", type=Path, required=True) + parser.add_argument("--project-dir", type=Path, required=True) + parser.add_argument("--binary-dir", type=Path, required=True) + args = parser.parse_args() + try: + generate(args.idf_path, args.project_dir, args.binary_dir) + except (OverrideError, OSError, UnicodeError, KeyError) as error: + print(f"security overrides: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())