Compare commits
2 Commits
22227b7e21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 039a60848d | |||
| 0f7cfdb819 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ icon.png
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/esp32_arduino/secrets.h
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*
|
*
|
||||||
* Provides REST API for Home Assistant integration
|
* Provides REST API for Home Assistant integration
|
||||||
*/
|
*/
|
||||||
// version 1.8 Initial release
|
// version 2.1 Initial release
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#include <WebServer.h>
|
#include <WebServer.h>
|
||||||
#include <WiFiClient.h>
|
#include <WiFiClient.h>
|
||||||
@@ -24,6 +24,13 @@
|
|||||||
#define NFC_RX_PIN 5 // UEXT1 pin 4 — ESP32 receives from PN532
|
#define NFC_RX_PIN 5 // UEXT1 pin 4 — ESP32 receives from PN532
|
||||||
#define NFC_POLL_MS 500 // idle detection interval (ms)
|
#define NFC_POLL_MS 500 // idle detection interval (ms)
|
||||||
|
|
||||||
|
// ── 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
|
HardwareSerial nfcSerial(1); // UART1
|
||||||
PN532_HSU pn532hsu(nfcSerial);
|
PN532_HSU pn532hsu(nfcSerial);
|
||||||
PN532 nfc(pn532hsu);
|
PN532 nfc(pn532hsu);
|
||||||
@@ -166,7 +173,29 @@ void setup() {
|
|||||||
Serial.println(" Check: correct SSID/password, 2.4 GHz band, board in range.");
|
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.");
|
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
|
// Setup API endpoints
|
||||||
server.on("/", handleRoot);
|
server.on("/", handleRoot);
|
||||||
server.on("/api/status", HTTP_GET, handleStatus);
|
server.on("/api/status", HTTP_GET, handleStatus);
|
||||||
@@ -186,7 +215,11 @@ void setup() {
|
|||||||
server.on("/nfc/config", HTTP_GET, handleNFCConfigGet);
|
server.on("/nfc/config", HTTP_GET, handleNFCConfigGet);
|
||||||
server.on("/nfc/config", HTTP_POST, handleNFCConfigSet);
|
server.on("/nfc/config", HTTP_POST, handleNFCConfigSet);
|
||||||
server.on("/debug", HTTP_GET, handleDebug);
|
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
|
// Start server
|
||||||
server.begin();
|
server.begin();
|
||||||
Serial.println("\n✓ HTTP server started on port 80");
|
Serial.println("\n✓ HTTP server started on port 80");
|
||||||
@@ -398,6 +431,8 @@ void scanWiFiNetworks() {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
void handleRoot() {
|
void handleRoot() {
|
||||||
|
if (!requireAuth()) return;
|
||||||
|
|
||||||
// Read all inputs first to get current state
|
// Read all inputs first to get current state
|
||||||
input1_state = digitalRead(DIN1_PIN);
|
input1_state = digitalRead(DIN1_PIN);
|
||||||
input2_state = digitalRead(DIN2_PIN);
|
input2_state = digitalRead(DIN2_PIN);
|
||||||
@@ -635,13 +670,95 @@ void handleRoot() {
|
|||||||
server.send(200, "text/html", html);
|
server.send(200, "text/html", html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Web UI authentication helper ────────────────────────────────────────
|
||||||
|
bool requireAuth() {
|
||||||
|
if (!server.authenticate(WEB_USER, WEB_PASSWORD)) {
|
||||||
|
server.requestAuthentication(DIGEST_AUTH, "ESP32-C6 Control Panel",
|
||||||
|
"Login required");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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
|
// Status request monitoring
|
||||||
static unsigned long last_status_log = 0;
|
static unsigned long last_status_log = 0;
|
||||||
static int status_request_count = 0;
|
static int status_request_count = 0;
|
||||||
|
|
||||||
void handleStatus() {
|
void handleStatus() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
status_request_count++;
|
status_request_count++;
|
||||||
|
|
||||||
// Read fresh input states
|
// Read fresh input states
|
||||||
input1_state = digitalRead(DIN1_PIN);
|
input1_state = digitalRead(DIN1_PIN);
|
||||||
input2_state = digitalRead(DIN2_PIN);
|
input2_state = digitalRead(DIN2_PIN);
|
||||||
@@ -679,6 +796,7 @@ void handleStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleRelayOn() {
|
void handleRelayOn() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
if (!server.hasArg("relay")) {
|
if (!server.hasArg("relay")) {
|
||||||
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
||||||
return;
|
return;
|
||||||
@@ -705,6 +823,7 @@ void handleRelayOn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleRelayOff() {
|
void handleRelayOff() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
if (!server.hasArg("relay")) {
|
if (!server.hasArg("relay")) {
|
||||||
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
||||||
return;
|
return;
|
||||||
@@ -731,6 +850,7 @@ void handleRelayOff() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleRelayStatus() {
|
void handleRelayStatus() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
if (!server.hasArg("relay")) {
|
if (!server.hasArg("relay")) {
|
||||||
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
||||||
return;
|
return;
|
||||||
@@ -754,6 +874,7 @@ void handleRelayStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleInputStatus() {
|
void handleInputStatus() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
if (!server.hasArg("input")) {
|
if (!server.hasArg("input")) {
|
||||||
server.send(400, "application/json", "{\"error\":\"Missing input parameter\"}");
|
server.send(400, "application/json", "{\"error\":\"Missing input parameter\"}");
|
||||||
return;
|
return;
|
||||||
@@ -779,6 +900,7 @@ void handleInputStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleLEDOn() {
|
void handleLEDOn() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
digitalWrite(LED_PIN, LOW); // LED is active-low
|
digitalWrite(LED_PIN, LOW); // LED is active-low
|
||||||
led_state = true;
|
led_state = true;
|
||||||
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||||
@@ -786,6 +908,7 @@ void handleLEDOn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleLEDOff() {
|
void handleLEDOff() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
digitalWrite(LED_PIN, HIGH); // LED is active-low
|
digitalWrite(LED_PIN, HIGH); // LED is active-low
|
||||||
led_state = false;
|
led_state = false;
|
||||||
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
||||||
@@ -804,6 +927,9 @@ void handleNotFound() {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
void handleRegister() {
|
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")) {
|
if (!server.hasArg("callback_url")) {
|
||||||
server.send(400, "application/json", "{\"error\":\"Missing callback_url parameter\"}");
|
server.send(400, "application/json", "{\"error\":\"Missing callback_url parameter\"}");
|
||||||
return;
|
return;
|
||||||
@@ -916,6 +1042,7 @@ void postInputEvent(int input_num, bool state) {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
void handleNFCStatus() {
|
void handleNFCStatus() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
bool present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0);
|
bool present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0);
|
||||||
String json = "{";
|
String json = "{";
|
||||||
json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ",";
|
json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ",";
|
||||||
@@ -962,6 +1089,7 @@ int nfcRelayPin(int rnum) {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
void handleNFCConfigGet() {
|
void handleNFCConfigGet() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
String json = "{";
|
String json = "{";
|
||||||
json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\",";
|
json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\",";
|
||||||
json += "\"relay_num\":" + String(nfc_relay_num) + ",";
|
json += "\"relay_num\":" + String(nfc_relay_num) + ",";
|
||||||
@@ -971,6 +1099,7 @@ void handleNFCConfigGet() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void handleNFCConfigSet() {
|
void handleNFCConfigSet() {
|
||||||
|
if (!verifyAPIRequest()) return;
|
||||||
if (server.hasArg("auth_uid")) {
|
if (server.hasArg("auth_uid")) {
|
||||||
String u = server.arg("auth_uid");
|
String u = server.arg("auth_uid");
|
||||||
u.trim();
|
u.trim();
|
||||||
@@ -1016,6 +1145,8 @@ void handleNFCConfigSet() {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
void handleDebug() {
|
void handleDebug() {
|
||||||
|
if (!requireAuth()) return;
|
||||||
|
|
||||||
String out = "=== ESP32-C6 Debug ===\n";
|
String out = "=== ESP32-C6 Debug ===\n";
|
||||||
out += "Uptime: " + String(millis() / 1000) + " s\n";
|
out += "Uptime: " + String(millis() / 1000) + " s\n";
|
||||||
out += "Free heap: " + String(ESP.getFreeHeap()) + " bytes\n";
|
out += "Free heap: " + String(ESP.getFreeHeap()) + " bytes\n";
|
||||||
|
|||||||
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