From 5ec678dfa3c89ef002d9b94935c40d9636a42c68 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 11 May 2026 11:26:44 -0700 Subject: [PATCH] fix: tighten version parsing, propagate HMAC sign failure, add deployment docs Co-Authored-By: Claude Sonnet 4.6 (1M context) --- firmware/lib/ota_updater/ota_updater.cpp | 18 ++++++++++--- server/ota_endpoint.py | 33 +++++++++++++++++------- server/test_ota_endpoint.py | 7 +++++ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/firmware/lib/ota_updater/ota_updater.cpp b/firmware/lib/ota_updater/ota_updater.cpp index b646dbc..0350c96 100644 --- a/firmware/lib/ota_updater/ota_updater.cpp +++ b/firmware/lib/ota_updater/ota_updater.cpp @@ -79,16 +79,17 @@ void ota_updater_init(const char* server_base, const char* device_id, s_last_check_ms = 0; // force first check on next call } -static void add_hmac_headers(HTTPClient& http, const char* method, const char* path) { +static bool add_hmac_headers(HTTPClient& http, const char* method, const char* path) { uint32_t ts = (uint32_t)(esp_timer_get_time() / 1000000ULL); String sig = hmac_sign(s_hmac_secret, method, path, ts, ""); if (sig.isEmpty()) { log_e("[OTA] HMAC sign failed"); - return; + return false; } http.addHeader("X-Device-Id", s_device_id); http.addHeader("X-Timestamp", String(ts)); http.addHeader("X-HMAC-Signature", sig); + return true; } static bool download_and_flash(const char* fw_url, size_t expected_size, @@ -111,7 +112,12 @@ static bool download_and_flash(const char* fw_url, size_t expected_size, HTTPClient http; http.begin(fw_url); - add_hmac_headers(http, "GET", "/ota/firmware"); + if (!add_hmac_headers(http, "GET", "/ota/firmware")) { + log_e("[OTA] Aborting firmware download: HMAC sign failed"); + mbedtls_sha256_free(&sha_ctx); + esp_ota_abort(handle); + return false; + } int code = http.GET(); if (code != HTTP_CODE_OK) { log_e("[OTA] Firmware fetch failed: HTTP %d", code); @@ -177,7 +183,11 @@ bool ota_updater_check_and_apply() { HTTPClient http; http.begin(check_url); - add_hmac_headers(http, "GET", check_path); + if (!add_hmac_headers(http, "GET", check_path)) { + log_e("[OTA] Aborting check: HMAC sign failed"); + http.end(); + return false; + } int code = http.GET(); if (code != HTTP_CODE_OK) { log_w("[OTA] Check failed: HTTP %d", code); diff --git a/server/ota_endpoint.py b/server/ota_endpoint.py index c1ee4be..f3e4adf 100644 --- a/server/ota_endpoint.py +++ b/server/ota_endpoint.py @@ -2,18 +2,29 @@ """ OTA firmware update endpoints. -To register in the server main app: - from server.ota_endpoint import router as ota_router - app.include_router(ota_router) +Deployment workflow: + 1. Generate signing key (one-time): + python tools/gen_signing_key.py + → secrets/firmware_signing_key.pem (keep offline) + → firmware/lib/ota_updater/ota_pubkey.h (commit this) -Route handlers have HMAC auth commented out pending import-path confirmation: - from .main import verify_device_hmac # adjust to actual module -Then uncomment the Depends lines in ota_check() and ota_firmware(). + 2. Build and deploy a new firmware version: + pio run -e timercam # build + python tools/deploy_firmware.py \\ + firmware/.pio/build/timercam/firmware.bin 1.2.3 + → server/firmware/ updated with current.bin, current.sig, manifest.json -Firmware artifacts expected under FIRMWARE_DIR (default: server/firmware/): - current.bin — raw firmware binary - current.sig — 64-byte r‖s Ed25519/ECDSA signature - manifest.json — {"version": "X.Y.Z", "size": N, "sha256": "hex..."} + 3. Bump FW_VERSION in firmware/include/version.h before each release. + + 4. Register in server main app: + from server.ota_endpoint import router as ota_router + app.include_router(ota_router) + Also uncomment Depends(verify_device_hmac) on both route handlers + and confirm the HMAC format matches hmac.cpp: + method + "\\n" + path + "\\n" + timestamp + "\\n" + sha256_hex(body) + +Note: HMAC auth is currently commented out on route handlers — must be wired +before production use. verify_device_hmac must use the same format as hmac.cpp. """ import base64 import json @@ -35,6 +46,8 @@ def _parse_version(v: str) -> tuple: """Parse semver string to comparable tuple; returns (0,0,0) on malformed input.""" try: parts = v.strip().split(".") + if len(parts) != 3: + return (0, 0, 0) return tuple(int(x) for x in parts) except (ValueError, AttributeError): return (0, 0, 0) diff --git a/server/test_ota_endpoint.py b/server/test_ota_endpoint.py index 8f7a924..b842779 100644 --- a/server/test_ota_endpoint.py +++ b/server/test_ota_endpoint.py @@ -74,3 +74,10 @@ def test_check_malformed_manifest(tmp_path): (tmp_path / "manifest.json").write_text("not valid json{{{") result = ota_check_impl(current_version="1.0.0", firmware_dir=tmp_path) assert result["update"] is False + + +def test_check_wrong_arity_version_no_update(tmp_path): + write_firmware(tmp_path, "1.2") # wrong arity server version + result = ota_check_impl(current_version="1.0.0", firmware_dir=tmp_path) + # server "1.2" → (0,0,0) ≤ client (1,0,0) → no update + assert result["update"] is False