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
+2
View File
@@ -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"
+62
View File
@@ -0,0 +1,62 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_memory.h"
#include <string.h>
#include "sdkconfig.h"
#include "esp_heap_caps.h"
#include "esp_idf_version.h"
#include "secure_random.h"
#if !defined(CONFIG_HEAP_POISONING_DISABLED) || !CONFIG_HEAP_POISONING_DISABLED || \
(defined(CONFIG_HEAP_POISONING_LIGHT) && CONFIG_HEAP_POISONING_LIGHT) || \
(defined(CONFIG_HEAP_POISONING_COMPREHENSIVE) && CONFIG_HEAP_POISONING_COMPREHENSIVE)
#error "SSH memory requires heap poisoning disabled"
#endif
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 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;
}
+31
View File
@@ -0,0 +1,31 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Install these hooks before wolfSSH/wolfSSL allocations begin. No module state,
* locks or allocation headers. Free/realloc accept only live allocation BASE
* pointers from this allocator (or NULL), never interior pointers.
*
* PSRAM is preferred, with internal 8-bit heap fallback. malloc(0) follows the
* SDK allocator. free wipes the full owned usable extent. realloc(NULL, size)
* delegates to malloc; realloc(non-NULL, 0) securely frees and returns NULL.
*
* Realloc within the usable extent retains the pointer AND capacity: it wipes
* [size, capacity), but does not reclaim memory. Growth copies the entire old
* usable extent, not merely the original requested size. Both allocations are
* live until copying finishes; failure leaves the old allocation unchanged.
* This is heap-retirement cleanup, not a guarantee about live library buffers
* or stack secrets. Only the guarded, audited unpoisoned IDF build is supported.
*/
void *ssh_memory_malloc(size_t size);
void ssh_memory_free(void *pointer);
void *ssh_memory_realloc(void *pointer, size_t size);
#ifdef __cplusplus
}
#endif
+37
View File
@@ -0,0 +1,37 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "ssh_protocol_policy.h"
#include <stddef.h>
#include <wolfssh/error.h>
#include <wolfssh/version.h>
#if LIBWOLFSSH_VERSION_HEX != 0x01004020
#error "Re-audit SSH protocol policy and algorithm-list contracts for this wolfSSH version"
#endif
/* wolfSSH borrows these pointers in both contexts and sessions. */
static const char s_kex[] = "curve25519-sha256,ecdh-sha2-nistp256";
static const char s_host_key[] = "ecdsa-sha2-nistp256";
static const char s_cipher[] = "aes128-gcm@openssh.com,aes256-gcm@openssh.com";
static const char s_mac[] = "hmac-sha2-256";
/* server-sig-algs advertisement only; the user database enforces enrollment. */
static const char s_key_accepted[] = "ssh-ed25519,ecdsa-sha2-nistp256";
int ssh_protocol_policy_apply(WOLFSSH_CTX *context)
{
if (context == NULL) {
return WS_SSH_CTX_NULL_E;
}
int result = wolfSSH_CTX_SetAlgoListKex(context, s_kex);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListKey(context, s_host_key);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListCipher(context, s_cipher);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListMac(context, s_mac);
if (result != WS_SUCCESS) return result;
result = wolfSSH_CTX_SetAlgoListKeyAccepted(context, s_key_accepted);
if (result != WS_SUCCESS) return result;
return WS_SUCCESS;
}
+10
View File
@@ -0,0 +1,10 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <wolfssh/ssh.h>
/* Apply before publishing the context or creating sessions. Returns WS_SUCCESS
* or the first setter error (WS_SSH_CTX_NULL_E for NULL). Failure may leave a
* partially configured context: the caller must discard it, never fall back to
* defaults. Does not allocate, free, or publish the context. */
int ssh_protocol_policy_apply(WOLFSSH_CTX *context);
+10 -20
View File
@@ -10,7 +10,7 @@
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
@@ -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 <wolfssl/wolfcrypt/memory.h>
#include <wolfssl/wolfcrypt/random.h>
@@ -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;
}
+36 -19
View File
@@ -115,6 +115,26 @@ static esp_err_t failure(httpd_req_t *r, const char *status, const char *code)
return response(r, status, body);
}
/* An early probe never charges or advances the verification window. Only the
* post-parse reservation commits it; retry == 0 means budget is available. */
static bool verification_budget(uint64_t epoch, bool reserve, int64_t *retry)
{
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
bool ready = s_ready && epoch == s_epoch;
int64_t window = s_window;
unsigned attempts = s_attempts;
if (now - window >= WINDOW_US) { window = now; attempts = 0; }
*retry = attempts < 5 ? 0 : (window + WINDOW_US - now + 999999LL) / 1000000LL;
if (ready && reserve && attempts < 5) {
s_window = window;
s_attempts = attempts + 1;
++s_counts.login_attempts;
}
taskEXIT_CRITICAL(&s_lock);
return ready;
}
static esp_err_t capacity(httpd_req_t *r)
{
if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
@@ -248,7 +268,8 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
bool document = !strcmp(r->uri, "/login");
web_session_view_t view = {0};
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
char set_cookie[180] = {0}, body[513] = {0};
char set_cookie[180] = {0}, body[513] = {0}, retry_after[16] = {0};
int64_t retry = 0;
web_auth_credentials_t credentials = {0};
challenge_t candidate = {0};
uint8_t digest[32] = {0}, origin_digest[32] = {0};
@@ -361,6 +382,10 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
}
if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; }
if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup;
if (!verification_budget(epoch, false, &retry)) {
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
}
if (retry) goto throttled;
size_t received = 0;
int64_t deadline = now + 3000000LL;
while (received < r->content_len && esp_timer_get_time() < deadline) {
@@ -369,29 +394,15 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
received += (size_t)count;
}
if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny;
now = esp_timer_get_time();
unsigned attempts;
int64_t retry;
taskENTER_CRITICAL(&s_lock);
ready = s_ready && epoch == s_epoch;
if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; }
attempts = s_attempts;
if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; }
retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL;
taskEXIT_CRITICAL(&s_lock);
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (attempts >= 5) {
char seconds[16];
snprintf(seconds, sizeof(seconds), "%lld", (long long)retry);
if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup;
result = failure(r, "429 Too Many Requests", "throttled");
goto cleanup;
secure_wipe(body, sizeof(body));
if (!verification_budget(epoch, true, &retry)) {
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
}
if (retry) goto throttled;
bool authenticated = false;
user_principal_t principal = {0};
esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length,
credentials.password, credentials.password_length, &principal, &authenticated);
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
if (error == ESP_OK && authenticated)
error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view);
@@ -406,7 +417,13 @@ esp_err_t web_cookie_auth_handler(httpd_req_t *r)
result = response(r, "200 OK", "{\"authenticated\":true}");
if (result != ESP_OK) web_session_store_invalidate(view.id);
goto cleanup;
throttled:
status = "429 Too Many Requests"; code = "throttled";
snprintf(retry_after, sizeof(retry_after), "%lld", (long long)retry);
if (httpd_resp_set_hdr(r, "Retry-After", retry_after) != ESP_OK) goto cleanup;
deny:
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
result = failure(r, status, code);
cleanup:
if (challenge_published && result != ESP_OK) {