Add HMAC-SHA256 API auth, NTP sync, NFC access control improvements

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
This commit is contained in:
2026-03-15 16:42:33 +02:00
parent 0f7cfdb819
commit 039a60848d
3 changed files with 137 additions and 10 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ icon.png
# OS
.DS_Store
Thumbs.db
/esp32_arduino/secrets.h

View File

@@ -8,7 +8,7 @@
*
* Provides REST API for Home Assistant integration
*/
// version 1.9 Initial release
// version 2.1 Initial release
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiClient.h>
@@ -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.h> // 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();

View File

@@ -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"