mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
281c4cb0ce
ota_check_auth() previously returned true when s_ota_psk[0] == '\0'
("permissive for dev"). A freshly-flashed node — or any node where
nobody had provisioned an OTA PSK yet — accepted attacker-controlled
firmware over plain HTTP on port 8032 from any host on the WiFi. No
Secure Boot V2, no signed-image verification, no transport encryption.
Single LAN call could brick or backdoor a node.
This was flagged in the deep security review of PR #596 but was a
PRE-EXISTING bug in main, not new code from that PR — so it stood as
a critical-severity production issue until this commit.
Fix:
- ota_check_auth() now returns false when no PSK is provisioned, with
ESP_LOGW("OTA rejected: no PSK in NVS …") at the call site so the
operator can diagnose the rejection from serial logs
- ota_update_init() ESP_LOGW message updated to surface the new posture
at boot ("upload endpoint will REJECT all requests until provisioned")
- Doc comment on ota_check_auth() rewritten to make the contract
explicit and reference the audit
The OTA HTTP server itself still starts even when no PSK is set. That
lets the operator run `provision.py --ota-psk <hex>` over USB-CDC to
write the NVS key without reflashing the firmware. The upload endpoint
just refuses every request in the meantime.
Breaking change for any deployment that depended on the unauthenticated
OTA path working out of the box. Documented in CHANGELOG under
[Unreleased] / Security so it's visible at the next release cut.
Fix-marker RuView#596-ota-fail-closed (scripts/fix-markers.json)
requires the new behaviour and forbids the old "permissive for dev"
fallback strings, so a future revert fails CI.
279 lines
9.7 KiB
C
279 lines
9.7 KiB
C
/**
|
|
* @file ota_update.c
|
|
* @brief HTTP OTA firmware update for ESP32-S3 CSI Node.
|
|
*
|
|
* Uses ESP-IDF's native OTA API with rollback support.
|
|
* The HTTP server runs on port 8032 and accepts:
|
|
* POST /ota — firmware binary payload (application/octet-stream)
|
|
* GET /ota/status — current firmware version and partition info
|
|
*/
|
|
|
|
#include "ota_update.h"
|
|
|
|
#include <string.h>
|
|
#include "esp_log.h"
|
|
#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";
|
|
|
|
/** OTA HTTP server port. */
|
|
#define OTA_PORT 8032
|
|
|
|
/** 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 only when a PSK is provisioned AND the Bearer token
|
|
* matches it. An unprovisioned node refuses all OTA requests
|
|
* (fail-closed, see RuView#596 audit). The OTA server still starts so
|
|
* the operator can `provision.py --ota-psk <hex>` over USB-CDC without
|
|
* a reflash, but the upload endpoint will reject every request until
|
|
* the PSK is set.
|
|
*/
|
|
static bool ota_check_auth(httpd_req_t *req)
|
|
{
|
|
if (s_ota_psk[0] == '\0') {
|
|
/* No PSK provisioned — fail closed. Previously this returned
|
|
* true ("permissive for dev"), which let any host on the WiFi
|
|
* push attacker-controlled firmware to a freshly-flashed node.
|
|
* Plain HTTP transport + no Secure Boot V2 + no signed-image
|
|
* verification meant a single LAN call could brick or back-
|
|
* door a node. Reject until provisioned. */
|
|
ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk <hex>)");
|
|
return false;
|
|
}
|
|
|
|
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.
|
|
*/
|
|
static esp_err_t ota_status_handler(httpd_req_t *req)
|
|
{
|
|
const esp_app_desc_t *app = esp_app_get_description();
|
|
const esp_partition_t *running = esp_ota_get_running_partition();
|
|
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
|
|
|
|
char response[512];
|
|
int len = snprintf(response, sizeof(response),
|
|
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
|
|
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
|
|
"\"max_size\":%d}",
|
|
app->version, app->date, app->time,
|
|
running ? running->label : "unknown",
|
|
update ? update->label : "none",
|
|
OTA_MAX_SIZE);
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, len);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* POST /ota — receive and flash firmware binary.
|
|
*/
|
|
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) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
|
"Invalid firmware size (must be 1B - 900KB)");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
|
|
if (update_partition == NULL) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"No OTA partition available");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
esp_ota_handle_t ota_handle;
|
|
esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"OTA begin failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
/* Read firmware in chunks. */
|
|
char buf[1024];
|
|
int received = 0;
|
|
int total = 0;
|
|
|
|
while (total < req->content_len) {
|
|
received = httpd_req_recv(req, buf, sizeof(buf));
|
|
if (received <= 0) {
|
|
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
|
|
continue; /* Retry on timeout. */
|
|
}
|
|
ESP_LOGE(TAG, "OTA receive error at byte %d", total);
|
|
esp_ota_abort(ota_handle);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"Receive error");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
err = esp_ota_write(ota_handle, buf, received);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_write failed at byte %d: %s",
|
|
total, esp_err_to_name(err));
|
|
esp_ota_abort(ota_handle);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"OTA write failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
total += received;
|
|
if ((total % (64 * 1024)) == 0) {
|
|
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%.0f%%)",
|
|
total, req->content_len,
|
|
(float)total * 100.0f / (float)req->content_len);
|
|
}
|
|
}
|
|
|
|
err = esp_ota_end(ota_handle);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"OTA validation failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
err = esp_ota_set_boot_partition(update_partition);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
|
|
"Set boot partition failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "OTA update successful! Rebooting to partition '%s'...",
|
|
update_partition->label);
|
|
|
|
const char *resp = "{\"status\":\"ok\",\"message\":\"OTA update successful. Rebooting...\"}";
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, resp, strlen(resp));
|
|
|
|
/* Delay briefly to let the response flush, then reboot. */
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
esp_restart();
|
|
|
|
return ESP_OK; /* Never reached. */
|
|
}
|
|
|
|
/** Internal: start the HTTP server and register OTA endpoints. */
|
|
static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
|
{
|
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
|
config.server_port = OTA_PORT;
|
|
config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */
|
|
/* Increase receive timeout for large uploads. */
|
|
config.recv_wait_timeout = 30;
|
|
|
|
httpd_handle_t server = NULL;
|
|
esp_err_t err = httpd_start(&server, &config);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "Failed to start OTA HTTP server on port %d: %s",
|
|
OTA_PORT, esp_err_to_name(err));
|
|
if (out_handle) *out_handle = NULL;
|
|
return err;
|
|
}
|
|
|
|
httpd_uri_t status_uri = {
|
|
.uri = "/ota/status",
|
|
.method = HTTP_GET,
|
|
.handler = ota_status_handler,
|
|
.user_ctx = NULL,
|
|
};
|
|
httpd_register_uri_handler(server, &status_uri);
|
|
|
|
httpd_uri_t upload_uri = {
|
|
.uri = "/ota",
|
|
.method = HTTP_POST,
|
|
.handler = ota_upload_handler,
|
|
.user_ctx = NULL,
|
|
};
|
|
httpd_register_uri_handler(server, &upload_uri);
|
|
|
|
ESP_LOGI(TAG, "OTA HTTP server started on port %d", OTA_PORT);
|
|
ESP_LOGI(TAG, " GET /ota/status — firmware version info");
|
|
ESP_LOGI(TAG, " POST /ota — upload new firmware binary");
|
|
|
|
if (out_handle) *out_handle = server;
|
|
return ESP_OK;
|
|
}
|
|
|
|
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 upload endpoint will REJECT all requests until "
|
|
"provisioned (provision.py --ota-psk <hex>). Fail-closed per RuView#596.");
|
|
}
|
|
nvs_close(nvs);
|
|
} else {
|
|
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA upload endpoint will REJECT all "
|
|
"requests until provisioned. Fail-closed per RuView#596.", OTA_NVS_NAMESPACE);
|
|
}
|
|
|
|
return ota_start_server(NULL);
|
|
}
|
|
|
|
esp_err_t ota_update_init_ex(void **out_server)
|
|
{
|
|
return ota_start_server((httpd_handle_t *)out_server);
|
|
}
|