From 039a60848dffff43981ff1e78753c24b07c69ccd Mon Sep 17 00:00:00 2001 From: scheianu Date: Sun, 15 Mar 2026 16:42:33 +0200 Subject: [PATCH] Add HMAC-SHA256 API auth, NTP sync, NFC access control improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Add verifyAPIRequest(): accepts valid Digest Auth (browser) OR valid HMAC-SHA256 signature (driver) — fixes browser UI being blocked by auth - All 11 API endpoints require verifyAPIRequest() - /register exempt (bootstrap handshake, secret not yet exchanged) - Credentials moved to secrets.h (gitignored); secrets.h.example added NTP: - Sync time on boot for HMAC replay-prevention timestamp window (±60s) - server.collectHeaders() registers X-Request-Time / X-Request-Sig NFC: - Full NFC access control: auth UID, relay trigger, absent timeout - Live UID display, copy-to-auth button, save/clear settings from UI - Access state: idle / granted / denied with colour feedback --- .gitignore | 2 + esp32_arduino/esp32_arduino.ino | 130 +++++++++++++++++++++++++++++--- esp32_arduino/secrets.h.example | 15 ++++ 3 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 esp32_arduino/secrets.h.example diff --git a/.gitignore b/.gitignore index fc51a07..6f38589 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ icon.png # OS .DS_Store Thumbs.db +/esp32_arduino/secrets.h + diff --git a/esp32_arduino/esp32_arduino.ino b/esp32_arduino/esp32_arduino.ino index 518b0db..0c60064 100644 --- a/esp32_arduino/esp32_arduino.ino +++ b/esp32_arduino/esp32_arduino.ino @@ -8,7 +8,7 @@ * * Provides REST API for Home Assistant integration */ -// version 1.9 Initial release +// version 2.1 Initial release #include #include #include @@ -24,12 +24,12 @@ #define NFC_RX_PIN 5 // UEXT1 pin 4 — ESP32 receives from PN532 #define NFC_POLL_MS 500 // idle detection interval (ms) -// ── Web UI credentials ──────────────────────────────────────────────────── -// Only browser-facing pages (/ and /debug) are protected. -// All REST API endpoints are intentionally left open so the Location -// Management board driver can communicate without credentials. -#define WEB_USER "ske087" -#define WEB_PASSWORD "Matei@123" +// ── Credentials & shared secrets ───────────────────────────────────────────── +// Loaded from secrets.h which is NOT committed to version control. +// Copy secrets.h.example → secrets.h, fill in your values, then flash. +#include "secrets.h" +#include "mbedtls/md.h" // HMAC-SHA256 — API request authentication +#include // time() — NTP-based timestamp validation HardwareSerial nfcSerial(1); // UART1 PN532_HSU pn532hsu(nfcSerial); @@ -173,7 +173,29 @@ void setup() { Serial.println(" Check: correct SSID/password, 2.4 GHz band, board in range."); Serial.println(" HTTP server will start anyway \u2014 accessible once WiFi reconnects."); } - + + // ── NTP time sync ──────────────────────────────────────────────────────────── + // Required for HMAC timestamp validation in verifyAPIRequest(). + // Syncs wall-clock time; if no internet the timestamp check is skipped but + // HMAC verification still runs (proving knowledge of API_SECRET). + configTime(0, 0, "pool.ntp.org", "time.google.com"); + if (wifi_ok) { + Serial.print("NTP sync "); + time_t _ntp_now = time(nullptr); + for (int _i = 0; _i < 20 && _ntp_now < 1577836800L; _i++) { + delay(500); Serial.print("."); _ntp_now = time(nullptr); + } + Serial.println(); + if (_ntp_now > 1577836800L) { + char _tbuf[32]; + strftime(_tbuf, sizeof(_tbuf), "%Y-%m-%d %H:%M:%S UTC", gmtime(&_ntp_now)); + Serial.printf("\u2713 NTP synced: %s\n", _tbuf); + } else { + Serial.println("\u26a0 NTP sync failed \u2014 timestamp check disabled"); + } + } + // ───────────────────────────────────────────────────────────────────────────── + // Setup API endpoints server.on("/", handleRoot); server.on("/api/status", HTTP_GET, handleStatus); @@ -193,7 +215,11 @@ void setup() { server.on("/nfc/config", HTTP_GET, handleNFCConfigGet); server.on("/nfc/config", HTTP_POST, handleNFCConfigSet); server.on("/debug", HTTP_GET, handleDebug); - + + // ── Collect HMAC auth headers for verifyAPIRequest() ───────────────────────── + const char* _api_hdrs[] = {"X-Request-Time", "X-Request-Sig"}; + server.collectHeaders(_api_hdrs, 2); + // Start server server.begin(); Serial.println("\n✓ HTTP server started on port 80"); @@ -654,13 +680,85 @@ bool requireAuth() { return true; } +// ── HMAC-SHA256 API request verification ───────────────────────────────────── +// Returns true if the request carries a valid X-Request-Sig header (or if +// API_SECRET is empty, meaning auth is disabled). +// Message format: "METHOD:path:unix_timestamp" e.g. "GET:/status:1710512345" +// Timestamp must be within ±60 s of board time (requires NTP sync). +bool verifyAPIRequest() { + // ── Option 1: browser UI — valid Digest Auth credentials ───────────────── + // The browser cannot generate HMAC headers, so we accept Digest Auth as an + // alternative. requireAuth() is still the sole gate on the HTML pages. + if (server.authenticate(WEB_USER, WEB_PASSWORD)) return true; + + // ── Option 2: driver API — valid HMAC-SHA256 signature ─────────────────── + // If no secret configured, allow all requests (backward compat / first boot) + if (strlen(API_SECRET) == 0) return true; + + String ts_str = server.header("X-Request-Time"); + String sig_str = server.header("X-Request-Sig"); + + if (ts_str.length() == 0 || sig_str.length() == 0) { + server.send(401, "application/json", "{\"error\":\"missing auth headers\"}"); + return false; + } + + // Replay prevention: only validate timestamp when NTP is synced + time_t now = time(nullptr); + if (now > 1000000000L) { // NTP synced (year > 2001) + long ts = ts_str.toInt(); + if (abs((long)now - ts) > 60) { + server.send(401, "application/json", "{\"error\":\"timestamp out of window\"}"); + return false; + } + } + + // Build expected message: "METHOD:path:timestamp" + String method = (server.method() == HTTP_POST) ? "POST" : "GET"; + String msg = method + ":" + server.uri() + ":" + ts_str; + + // Compute HMAC-SHA256 + uint8_t hmac_out[32]; + mbedtls_md_context_t ctx; + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1); + mbedtls_md_hmac_starts(&ctx, + (const unsigned char *)API_SECRET, strlen(API_SECRET)); + mbedtls_md_hmac_update(&ctx, + (const unsigned char *)msg.c_str(), msg.length()); + mbedtls_md_hmac_finish(&ctx, hmac_out); + mbedtls_md_free(&ctx); + + // Hex-encode result + char expected[65]; + for (int i = 0; i < 32; i++) + sprintf(expected + i * 2, "%02x", hmac_out[i]); + expected[64] = '\0'; + + // Constant-time comparison + if (sig_str.length() != 64) { + server.send(401, "application/json", "{\"error\":\"invalid signature\"}"); + return false; + } + uint8_t diff = 0; + for (int i = 0; i < 64; i++) + diff |= (uint8_t)sig_str[i] ^ (uint8_t)expected[i]; + + if (diff != 0) { + server.send(401, "application/json", "{\"error\":\"invalid signature\"}"); + return false; + } + return true; +} + // Status request monitoring static unsigned long last_status_log = 0; static int status_request_count = 0; void handleStatus() { + if (!verifyAPIRequest()) return; status_request_count++; - + // Read fresh input states input1_state = digitalRead(DIN1_PIN); input2_state = digitalRead(DIN2_PIN); @@ -698,6 +796,7 @@ void handleStatus() { } void handleRelayOn() { + if (!verifyAPIRequest()) return; if (!server.hasArg("relay")) { server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}"); return; @@ -724,6 +823,7 @@ void handleRelayOn() { } void handleRelayOff() { + if (!verifyAPIRequest()) return; if (!server.hasArg("relay")) { server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}"); return; @@ -750,6 +850,7 @@ void handleRelayOff() { } void handleRelayStatus() { + if (!verifyAPIRequest()) return; if (!server.hasArg("relay")) { server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}"); return; @@ -773,6 +874,7 @@ void handleRelayStatus() { } void handleInputStatus() { + if (!verifyAPIRequest()) return; if (!server.hasArg("input")) { server.send(400, "application/json", "{\"error\":\"Missing input parameter\"}"); return; @@ -798,6 +900,7 @@ void handleInputStatus() { } void handleLEDOn() { + if (!verifyAPIRequest()) return; digitalWrite(LED_PIN, LOW); // LED is active-low led_state = true; server.send(200, "application/json", "{\"status\":\"ok\"}"); @@ -805,6 +908,7 @@ void handleLEDOn() { } void handleLEDOff() { + if (!verifyAPIRequest()) return; digitalWrite(LED_PIN, HIGH); // LED is active-low led_state = false; server.send(200, "application/json", "{\"status\":\"ok\"}"); @@ -823,6 +927,9 @@ void handleNotFound() { // ============================================ void handleRegister() { + // NOTE: /register is the bootstrap handshake — it intentionally has no HMAC + // guard because the api_secret cannot be exchanged until after registration. + // All other API endpoints require verifyAPIRequest(). if (!server.hasArg("callback_url")) { server.send(400, "application/json", "{\"error\":\"Missing callback_url parameter\"}"); return; @@ -935,6 +1042,7 @@ void postInputEvent(int input_num, bool state) { // ============================================ void handleNFCStatus() { + if (!verifyAPIRequest()) return; bool present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0); String json = "{"; json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ","; @@ -981,6 +1089,7 @@ int nfcRelayPin(int rnum) { // ============================================ void handleNFCConfigGet() { + if (!verifyAPIRequest()) return; String json = "{"; json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\","; json += "\"relay_num\":" + String(nfc_relay_num) + ","; @@ -990,6 +1099,7 @@ void handleNFCConfigGet() { } void handleNFCConfigSet() { + if (!verifyAPIRequest()) return; if (server.hasArg("auth_uid")) { String u = server.arg("auth_uid"); u.trim(); diff --git a/esp32_arduino/secrets.h.example b/esp32_arduino/secrets.h.example new file mode 100644 index 0000000..c4b3a90 --- /dev/null +++ b/esp32_arduino/secrets.h.example @@ -0,0 +1,15 @@ +// ── Board secrets (EXAMPLE — copy to secrets.h and fill in your values) ─────── +// DO NOT commit secrets.h to version control. +// +// API_SECRET — shared secret for HMAC-SHA256 API request authentication. +// Generate a strong random value with: +// python3 -c "import secrets; print(secrets.token_hex(32))" +// Then paste the same value into the board's Edit page in the Location +// Management server. +// +// WEB_USER / WEB_PASSWORD — credentials for the browser control panel. +// ───────────────────────────────────────────────────────────────────────────── + +#define API_SECRET "REPLACE_WITH_OUTPUT_OF_python3_-c_import_secrets_token_hex_32" +#define WEB_USER "your_username" +#define WEB_PASSWORD "your_password"