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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ icon.png
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/esp32_arduino/secrets.h
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
15
esp32_arduino/secrets.h.example
Normal file
15
esp32_arduino/secrets.h.example
Normal 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"
|
||||
Reference in New Issue
Block a user