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.
This commit is contained in:
2026-09-15 22:12:57 +02:00
parent 751dfb9ddb
commit cdc9c7335a
41 changed files with 3597 additions and 89 deletions
+143
View File
@@ -0,0 +1,143 @@
# Security library review — Phase 9C
## Scope and status
Bounded implementation/source audit, verified 2026-09-15; not library security certification.
Baseline: **ESP-IDF 5.5.0, mbedTLS 3.6.3, wolfSSH 1.4.20, wolfSSL 5.8.2~1**
(upstream wolfSSL version macro: 5.8.2). Original dependencies are not upgraded or hand-patched.
Versions were checked against installed headers and `src/idf_component.yml`; override hashes
were checked against installed originals. Source is authoritative over older integration notes.
The reported Phase 9C reviews have no remaining blocking finding; the HTTPD null-initial
read finding is fixed and covered by the passing host suite below.
Whole-Phase-9 target validation is deferred at the user's request; see [main policy](security_hardening.md).
The [main policy](security_hardening.md#validation-gates) records final firmware build/size evidence separately from this source review.
## Confirmed gaps fixed
| Boundary / source | Implemented correction |
|---|---|
| SDK `esp_https_server/src/https_server.c` | Delete TLS when post-handshake transport allocation fails; destroy the complete secure context on HTTPD start failure. Restore the original open callback and clear stale context/destructor pointers. Wipe `serverkey_bytes` before freeing the raw key copy. Failed stop retains live ownership. |
| SDK `esp_http_server/src/httpd_parse.c` | Replace scratch realloc with allocate/copy/wipe/free; preserve old pointer/content on allocation failure and wipe current scratch at final cleanup. Preserve pending/unread bytes. Initial reads avoid NULL subtraction and retain a NULL parser position until set; existing positions relocate correctly. |
| SDK `esp-tls/esp_tls_mbedtls.c` | Apply the server-local TLS profile below after defaults and before setup; static suite storage, TLS 1.2 minimum/maximum, no renegotiation. Client defaults/caller suites and global crypto features are unchanged. |
| wolfSSH `src/internal.c` | Use `GetSize()` bounds for password/new-password fields, reject invalid context/index, and guard authentication dispatch after new-password parse failure. Wipe the checked packet suffix before failure output, preserving the username/service/method prefix needed by the caller. Skip wiping on `WS_AUTH_PENDING` for retry; the project does not return pending. |
| `src/ssh_memory.c`, `src/ssh_transport.c` | Register secure wolfSSL/wolfSSH allocation hooks before library allocation; wipe retired heap extents and explicit shrink tails, including allocator rounding. |
| `src/ssh_protocol_policy.c`, `src/ssh_transport.c` | Apply all five explicit lists; any setter failure frees the unpublished candidate and returns failure, without default-policy fallback. |
| `src/web_cookie_auth.c` | Check exhausted verification budget before body receive/parse, reserve authoritatively after parsing, and shorten JSON/credential lifetime before backend/error output. |
The three SDK overrides and wolfSSH override are registered in `tools/security_overrides.py`.
Root `CMakeLists.txt` includes `cmake/security_overrides.cmake` **after `project()`**;
`src/CMakeLists.txt` includes both new SSH modules. No embedded web assets were regenerated.
## Heap and packet lifetime contract
`ssh_memory` compile-guards **unpoisoned IDF 5.5.0**: `heap_caps_get_allocated_size()`
must return the owned usable extent of a base allocation, not an interior-pointer extent.
Allocation remains PSRAM-first with internal fallback; no allocation headers, metadata tables,
extra locks or tasks are introduced. Free securely wipes the complete extent before release.
Shrink retains the pointer/capacity and wipes the discarded tail; it does not reclaim heap.
Growth allocates a replacement, copies the old usable extent, then wipes/frees the old allocation.
Failed growth leaves the old allocation and contents unchanged. Growth temporarily needs **old + new**
storage, including possible internal fallback. HTTPD resize similarly needs both bounded allocations,
but retains its ordinary shrink/grow behavior rather than a permanent maximum-sized scratch buffer.
These fixes cover specific retired copies, not every secret throughout its lifetime:
- Static and still-live library buffers can retain bytes; heap hooks do not intercept in-place compaction.
- Packet-suffix wiping is deliberately prefix-preserving and is not an asynchronous-auth wipe guarantee.
- Backend-specific spills, stack/register copies, crypto intermediates and accelerator state were not exhaustively audited.
- Browser memory, flash history and all allocator regions are not proven clean; do not export raw memory dumps as evidence.
## Existing cleanup verified, not presumed broken
Inspection of the installed original sources found existing wipes on the checked normal paths:
- wolfSSL `wolfcrypt/src/ecc.c:wc_ecc_free()` calls `mp_forcezero()` for the private scalar;
`integer.c` wipes used digits before release, while `tfm.c` delegates to `fp_forcezero()`.
- mbedTLS `library/pk_wrap.c:eckey_free_wrap()` delegates to `ecp.c:mbedtls_ecp_keypair_free()`;
the private MPI reaches `bignum.c:mbedtls_mpi_free()` and its zeroize-and-free path.
- mbedTLS `library/md.c:mbedtls_md_free()` zeroizes/frees HMAC pads and wipes the context.
- mbedTLS `library/ssl_tls.c:mbedtls_ssl_free()` zeroizes/frees input/output record buffers;
its inspected buffer-resize path also zeroizes retired storage.
Thus ordinary destructors are **not generally broken across both stacks**. The confirmed gaps above
are separate raw-copy, ownership, resize and packet-lifetime issues. IDF dynamic TLS buffers are
compile-rejected because their destruction bypasses the inspected upstream record-buffer path;
other configurations/backends need their own review, not extrapolation from these observations.
## Current protocol allowlists and compatibility
| Layer / setting | Exact current policy |
|---|---|
| HTTPS versions | TLS 1.2 only; renegotiation disabled or compiled out |
| HTTPS suites | `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`, `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384` |
| SSH `Kex` | `curve25519-sha256,ecdh-sha2-nistp256` |
| SSH `Key` (host identity) | `ecdsa-sha2-nistp256` |
| SSH `Cipher` (both directions) | `aes128-gcm@openssh.com,aes256-gcm@openssh.com` |
| SSH `Mac` (both advertised directions) | `hmac-sha2-256`; GCM provides the negotiated AEAD integrity |
| SSH `KeyAccepted` | `ssh-ed25519,ecdsa-sha2-nistp256` (`server-sig-algs` advertisement only) |
| SSH compression | `none` |
| SSH authentication | Password or enrolled Ed25519/ECDSA-P256 public key; keyboard-interactive rejected |
SSH list strings have static lifetime because contexts/sessions borrow their pointers. List setters
alone do not validate compiled support; the source/production-feature tests check names, IDs and
serialized initial/rekey lists. Enrollment/authorization remains in the user database, not `KeyAccepted`.
Legacy CBC/CTR-only SSH clients, excluded KEX/host-key clients, and CBC-only TLS clients cannot connect;
TLS clients need TLS 1.2 plus one listed ECDHE-ECDSA GCM suite (TLS-1.3-only also fails).
There is no automatic compatibility fallback. Modern-client compatibility is still a live-test gate,
not a claim that signature verification, real KEX/rekey or TLS/SSH handshakes were exercised here.
## Web admission and retained credential/browser policy
The early quota probe neither consumes attempts nor advances the window. The final post-parse
reservation preserves **five password verifications per 60 seconds globally**; malformed requests
are not charged. Exhausted requests avoid body receive/parser/KDF and close without draining unread
bodies. Challenges remain consumable before this probe: this does **not** establish challenge fairness
or prevent global starvation. HTTPS service stop/start resets this window/challenges, unlike SSH's
boot-lifetime admission buckets. Epoch/readiness checks fence stale work at both quota boundaries.
Raw JSON is wiped after parsing and before KDF; parsed credentials immediately after authentication;
denial paths wipe both before error responses. Ordinary final request/token cleanup remains in place.
`src/user_database.{c,h}` remains unchanged: **1264 printable ASCII bytes** (`0x20``0x7e`),
PBKDF2-HMAC-SHA256 with **50,000 iterations**, **16-byte random salt**, **32-byte verifier**.
Generated passwords select **24 symbols from 64**, giving **144 bits** with uniform secure randomness.
This is a reviewed retained baseline, not a claim that 50,000 iterations meets every current deployment
recommendation. Benchmark target verification latency and mixed-load headroom before choosing a new
cost; do not blindly increase it. No verifier storage format or key-rotation behavior changes here.
`src/web_security.c` generates a self-signed **P-256 / ECDSA-SHA256** certificate, non-CA,
digital-signature usage, server-auth EKU, device DNS and fixed AP IPv4 SANs, with fixed validity
**2025-01-01 through 2049-12-31**. Existing validation checks the key pair, expected fields/SANs and
self-signature; this inspection is not a new real-crypto signature-verification test.
Compare the certificate SHA-256 fingerprint through trusted UART0 (`web certificate info`) before
accepting browser trust; a warning bypass is not verification, nor is arbitrary STA-IP trust solved.
Existing persistence/rotation/recovery contracts remain unchanged; NVS is not newly encrypted.
`src/web_login_ui.c`, `src/web_ui.c` and `src/web_cookie_auth.c` retain CSP, document/auth
`Cache-Control: no-store`, and `Secure; HttpOnly; SameSite=Strict` cookies. Static assets retain their
separate caching policy. HSTS is deliberately not blindly forced for the self-signed hostname/IP
workflow: it is not a substitute for verified certificate trust and may obstruct recovery.
## Maintenance and evidence
1. Keep the original SDK/managed sources untouched. Maintain reviewed `Entry` hashes and exact-once
edits in `tools/security_overrides.py`; never repin a hash merely to make configuration succeed.
2. Re-audit changed source ownership, cleanup, allocator extents, algorithms and resolved features.
Full original SHA-256/version mismatch, missing/ambiguous edits or source registration fail closed.
3. CMake retains component targets and source properties/quoted-include context, replacing exactly one
original compilation per entry. Generator/version/original/derived changes trigger reconfiguration;
changed originals fail the hash check. Do not hand-patch SDK files or derived build-tree output.
4. Derived full files preserve original copyright/license notices; regenerate through configuration,
verify exact generated bytes and single-source registration, then rerun the relevant host contracts.
5. Future release/dependency review remains pending: external advisories and license obligations have
**not** been reviewed here. No CVE absence, vulnerability completeness or license-compliance claim.
Verified host commands passed during this documentation audit (prefix `CCACHE_DISABLE=1`):
- `python3 tests/sdk_security_overrides/run.py --build-dir .pio/build/esp32-s3-devkitc-1-n16r8`
- `python3 tests/ssh_memory/run.py --idf-path /home/mscholz/.platformio/packages/framework-espidf`
- `python3 tests/ssh_protocol_policy/run.py`
- `python3 tests/wolfssh_auth_contract/run.py`
- `python3 tests/web_cookie_auth/run.py`
These execute actual modules/extracted installed or patched functions with heap, crypto, IO and layout
mocks, plus pinned source/production-feature contracts and existing Ninja registration checks.
They cover cleanup failures, null-first-read behavior, policy serialization/publication and web quota/wipe
ordering; they are not complete parser fuzzing, real signature verification, live handshakes or target tests.
No firmware build, upload, erase, raw-dump export or hardware operation was performed for this document.