# 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.