fix: security hardening — replace fake HMAC, add path traversal protection, OTA auth (ADR-050)

Sprint 1 security fixes from quality engineering analysis (issue #170):

- Replace XOR-fold fake HMAC with real HMAC-SHA256 (hmac + sha2 crates) in secure_tdm.rs
- Add path traversal sanitization on DELETE /api/v1/models/:id and /api/v1/recording/:id
- Default bind address changed from 0.0.0.0 to 127.0.0.1 (configurable via --bind-addr / SENSING_BIND_ADDR)
- Add PSK authentication to ESP32 OTA firmware upload endpoint (ota_update.c)
- Flip WASM signature verification to default-on (CONFIG_WASM_SKIP_SIGNATURE opt-out vs opt-in)
- Add 6 new security tests: HMAC key/message sensitivity, determinism, wrong-key rejection, bit-flip detection, enforcing mode
- Add clap env feature for environment variable configuration

All 106 hardware crate tests pass. Sensing server compiles clean.

Closes #170

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-03-06 13:11:04 -05:00
parent c45690ed4e
commit 47223a98be
8 changed files with 313 additions and 20 deletions
+70
View File
@@ -15,6 +15,8 @@
#include "esp_ota_ops.h"
#include "esp_http_server.h"
#include "esp_app_desc.h"
#include "nvs_flash.h"
#include "nvs.h"
static const char *TAG = "ota_update";
@@ -24,6 +26,52 @@ static const char *TAG = "ota_update";
/** Maximum firmware size (900 KB — matches CI binary size gate). */
#define OTA_MAX_SIZE (900 * 1024)
/** NVS namespace and key for the OTA pre-shared key. */
#define OTA_NVS_NAMESPACE "security"
#define OTA_NVS_KEY "ota_psk"
/** Maximum PSK length (hex-encoded SHA-256). */
#define OTA_PSK_MAX_LEN 65
/** Cached PSK loaded from NVS at init time. Empty = auth disabled. */
static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
/**
* ADR-050: Verify the Authorization header contains the correct PSK.
* Returns true if auth is disabled (no PSK provisioned) or if the
* Bearer token matches the stored PSK.
*/
static bool ota_check_auth(httpd_req_t *req)
{
if (s_ota_psk[0] == '\0') {
/* No PSK provisioned — auth disabled (permissive for dev). */
return true;
}
char auth_header[128] = {0};
if (httpd_req_get_hdr_value_str(req, "Authorization", auth_header,
sizeof(auth_header)) != ESP_OK) {
return false;
}
/* Expect "Bearer <psk>" */
const char *prefix = "Bearer ";
if (strncmp(auth_header, prefix, strlen(prefix)) != 0) {
return false;
}
const char *token = auth_header + strlen(prefix);
/* Constant-time comparison to prevent timing attacks. */
size_t psk_len = strlen(s_ota_psk);
size_t tok_len = strlen(token);
if (psk_len != tok_len) return false;
volatile uint8_t result = 0;
for (size_t i = 0; i < psk_len; i++) {
result |= (uint8_t)(s_ota_psk[i] ^ token[i]);
}
return result == 0;
}
/**
* GET /ota/status — return firmware version and partition info.
*/
@@ -53,6 +101,14 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
*/
static esp_err_t ota_upload_handler(httpd_req_t *req)
{
/* ADR-050: Authenticate before accepting firmware upload. */
if (!ota_check_auth(req)) {
ESP_LOGW(TAG, "OTA upload rejected: authentication failed");
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"Authentication required. Use: Authorization: Bearer <psk>");
return ESP_FAIL;
}
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) {
@@ -187,6 +243,20 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
esp_err_t ota_update_init(void)
{
/* ADR-050: Load OTA PSK from NVS if provisioned. */
nvs_handle_t nvs;
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
size_t len = sizeof(s_ota_psk);
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
} else {
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
}
nvs_close(nvs);
} else {
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE);
}
return ota_start_server(NULL);
}
+6 -5
View File
@@ -107,8 +107,9 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
return ESP_FAIL;
}
/* Verify signature if wasm_verify is enabled. */
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
/* ADR-050: Verify signature (default-on; skip only if
* CONFIG_WASM_SKIP_SIGNATURE is explicitly set for dev/lab). */
#ifndef CONFIG_WASM_SKIP_SIGNATURE
{
/* Load pubkey from NVS config (set via provision.py --wasm-pubkey). */
extern nvs_config_t g_nvs_config;
@@ -173,11 +174,11 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
} else if (rvf_is_raw_wasm(buf, (uint32_t)total)) {
/* ── Raw WASM path (dev/lab only) ── */
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
#ifndef CONFIG_WASM_SKIP_SIGNATURE
free(buf);
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"Raw WASM upload rejected (wasm_verify enabled). "
"Use RVF container with signature.");
"Raw WASM upload rejected (signature verification enabled). "
"Use RVF container with signature, or set CONFIG_WASM_SKIP_SIGNATURE for dev.");
return ESP_FAIL;
#else
format = "raw";