Replace HMAC-SHA256 with AES-256-GCM + ECDH key exchange for BLE relay commands (issue #2)

- New: src/ecdh_helper.h — ECDH keypair gen, shared secret, pubkey HMAC sign/verify
- Server (src/main.cpp): ECDH handshake callback, AES-GCM decrypt, PSK from NVS
- Client (src/main.cpp→client.cpp): ECDH after connect, AES-GCM encrypt, PSK from NVS
- Android: ECDH (KeyAgreement) + AES-GCM (Cipher) in BleRelayManager.kt
- PSK provisioned via USB CDC SET_KEY (SettingsActivity.kt)
- PrefsHelper: hmac_key → psk
- Bumped versions: server=18, client=4
- NVS namespace: hmac→syl, key:hmac_key→psk
- BLE ECDH characteristic: beb5483e-...-f26dc (WRITE_NR|NOTIFY)
This commit is contained in:
eddy 2026-08-03 04:33:52 +02:00
commit d6f1062a44
11 changed files with 788 additions and 173 deletions

View file

@ -2,12 +2,13 @@
#include <NimBLEDevice.h>
#include <FastLED.h>
#include <mbedtls/md.h>
#include <mbedtls/gcm.h>
#include <mbedtls/sha256.h>
#include <Preferences.h>
#include <esp_log.h>
#include "version.h"
#include "ecdh_helper.h"
static const char *TAG = "client";
@ -22,21 +23,20 @@ CRGB leds[NUM_LEDS];
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define HMAC_PKT_SIZE 37
#define HMAC_KEY_LEN 32
#define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc"
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac"
#define NVS_KEY_NAME "key"
#define NVS_NAMESPACE "syl"
#define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid"
#define NVS_SERVER_NAME "server"
#define DEBOUNCE_MS 50
#define SCAN_INTERVAL 6000
#define SCAN_TIMEOUT 5000
uint8_t hmacKey[HMAC_KEY_LEN];
uint8_t psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
uint8_t serverUuid[ESP_UUID_LEN];
bool keyProvisioned = false;
bool pskProvisioned = false;
bool uuidProvisioned = false;
bool serverProvisioned = false;
bool allProvisioned = false;
@ -50,6 +50,9 @@ static bool relay1SendState = false;
static bool relay2SendState = false;
static uint32_t seq = 0;
static uint8_t aesKey[AES_KEY_LEN];
static bool ecdhReady = false;
#define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0;
@ -60,6 +63,10 @@ static bool bleReady = false;
static bool serverFound = false;
static NimBLEAddress serverAddress;
static bool ecdhNotifyReceived = false;
static uint8_t ecdhNotifyBuf[ECDH_PKT_SIZE];
static size_t ecdhNotifyLen = 0;
class MyScanCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override {
if (advertisedDevice->isAdvertisingService(NimBLEUUID(SERVICE_UUID))) {
@ -80,13 +87,15 @@ class MyClientCallbacks : public NimBLEClientCallbacks {
relay1SendState = false;
relay2SendState = false;
pCmdChar = nullptr;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
ESP_LOGW(TAG, "BLE: disconnected (reason=%d)", reason);
}
};
static MyClientCallbacks clientCallbacks;
static bool isHexKey(const String &hex) {
static bool isHex(const String &hex) {
for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false;
@ -94,24 +103,41 @@ static bool isHexKey(const String &hex) {
return true;
}
static void parseHexKey(const String &hex, uint8_t *out, int len) {
static void parseHex(const String &hex, uint8_t *out, int len) {
for (int i = 0; i < len; i++) {
out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
}
static void buildHmacPacket(uint8_t cmd, uint8_t *packet) {
static void buildAesPacket(uint8_t cmd, uint8_t *packet) {
uint32_t s = ++seq;
packet[0] = cmd;
packet[1] = (s >> 24) & 0xFF;
packet[2] = (s >> 16) & 0xFF;
packet[3] = (s >> 8) & 0xFF;
packet[4] = s & 0xFF;
uint8_t hmacData[5];
hmacData[0] = cmd;
memcpy(hmacData + 1, packet + 1, 4);
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(mdInfo, hmacKey, HMAC_KEY_LEN, hmacData, 5, packet + 5);
packet[0] = (s >> 24) & 0xFF;
packet[1] = (s >> 16) & 0xFF;
packet[2] = (s >> 8) & 0xFF;
packet[3] = s & 0xFF;
uint8_t nonceRandom[8];
for (int i = 0; i < 8; i++) nonceRandom[i] = (uint8_t)(esp_random() & 0xFF);
memcpy(packet + 4, nonceRandom, 8);
uint8_t nonce[12];
memcpy(nonce, packet, 4);
memcpy(nonce + 4, nonceRandom, 8);
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "AES: setkey failed (ret=%d)", ret);
mbedtls_gcm_free(&gcm);
return;
}
uint8_t pt[1] = { cmd };
mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT,
1, nonce, 12, NULL, 0,
pt, packet + 12, 16, packet + 13);
mbedtls_gcm_free(&gcm);
}
static void disconnectClient() {
@ -125,6 +151,89 @@ static void disconnectClient() {
connected = false;
relay1SendState = false;
relay2SendState = false;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
}
static bool performEcdhHandshake(NimBLERemoteService *pSvc) {
if (!pskProvisioned) {
ESP_LOGE(TAG, "ECDH: no PSK");
return false;
}
CryptoECDH ecdh;
if (!ecdh.init()) {
ESP_LOGE(TAG, "ECDH: init failed");
return false;
}
if (!ecdh.generateKeys()) {
ESP_LOGE(TAG, "ECDH: keygen failed");
ecdh.free();
return false;
}
NimBLERemoteCharacteristic *pEcdhChar = pSvc->getCharacteristic(ECDH_UUID);
if (pEcdhChar == nullptr) {
ESP_LOGW(TAG, "ECDH: characteristic not found");
ecdh.free();
return false;
}
if (!pEcdhChar->subscribe(true, [](NimBLERemoteCharacteristic *pChar, const uint8_t *data, size_t len, bool isNotify) {
if (ecdhReady) return;
if (len != ECDH_PKT_SIZE) {
ESP_LOGW(TAG, "ECDH: bad notify length %d", len);
return;
}
memcpy(ecdhNotifyBuf, data, len);
ecdhNotifyLen = len;
ecdhNotifyReceived = true;
ESP_LOGI(TAG, "ECDH: notify received (%d bytes)", len);
}, true)) {
ESP_LOGW(TAG, "ECDH: subscribe failed");
ecdh.free();
return false;
}
uint8_t handshake[ECDH_PKT_SIZE];
memcpy(handshake, ecdh.public_key, ECDH_PUBKEY_LEN);
CryptoECDH::signPubkey(psk, ecdh.public_key, handshake + ECDH_PUBKEY_LEN);
ecdhNotifyReceived = false;
if (!pEcdhChar->writeValue(handshake, ECDH_PKT_SIZE, false)) {
ESP_LOGW(TAG, "ECDH: write pubkey failed");
ecdh.free();
return false;
}
uint32_t start = millis();
while (!ecdhNotifyReceived && (millis() - start < 5000)) {
delay(10);
}
if (!ecdhNotifyReceived) {
ESP_LOGW(TAG, "ECDH: notify timeout");
ecdh.free();
return false;
}
if (!CryptoECDH::verifyPubkey(psk, ecdhNotifyBuf, ecdhNotifyBuf + ECDH_PUBKEY_LEN)) {
ESP_LOGW(TAG, "ECDH: server pubkey HMAC mismatch");
ecdh.free();
return false;
}
if (!ecdh.computeSharedSecret(ecdhNotifyBuf, ECDH_PUBKEY_LEN)) {
ESP_LOGE(TAG, "ECDH: shared secret failed");
ecdh.free();
return false;
}
memcpy(aesKey, ecdh.aes_key, AES_KEY_LEN);
ecdh.free();
ecdhReady = true;
ESP_LOGI(TAG, "ECDH: handshake complete");
return true;
}
static bool connectToServer() {
@ -167,10 +276,19 @@ static bool connectToServer() {
return false;
}
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
if (!performEcdhHandshake(pSvc)) {
ESP_LOGE(TAG, "BLE: ECDH handshake failed");
disconnectClient();
return false;
}
seq = 0;
relay1SendState = false;
relay2SendState = false;
ESP_LOGI(TAG, "BLE: server UUID verified, ready");
ESP_LOGI(TAG, "BLE: server UUID verified, ECDH done, ready");
return true;
}
@ -184,17 +302,17 @@ void setup() {
FastLED.show();
preferences.begin(NVS_NAMESPACE, true);
size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN);
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
size_t serverLen = preferences.getBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
if (len == HMAC_KEY_LEN) {
keyProvisioned = true;
ESP_LOGI(TAG, "HMAC key loaded from NVS");
if (pskLen == PSK_LEN) {
pskProvisioned = true;
ESP_LOGI(TAG, "PSK loaded from NVS");
} else {
keyProvisioned = false;
ESP_LOGW(TAG, "No HMAC key — use USB SET_KEY to provision");
pskProvisioned = false;
ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision");
}
if (uuidLen == ESP_UUID_LEN) {
@ -218,7 +336,7 @@ void setup() {
ESP_LOGW(TAG, "No server UUID — use USB SET_SERVER to provision");
}
allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
pinMode(TOUCH1_PIN, INPUT);
pinMode(TOUCH2_PIN, INPUT);
@ -248,12 +366,12 @@ void loop() {
if (c == '\n') {
serialBuf.trim();
if (serialBuf == "KEY?") {
if (keyProvisioned && uuidProvisioned) {
if (pskProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
Serial.print("HMAC_KEY:");
for (int i = 0; i < HMAC_KEY_LEN; i++) Serial.printf("%02X", hmacKey[i]);
Serial.print("PSK:");
for (int i = 0; i < PSK_LEN; i++) Serial.printf("%02X", psk[i]);
Serial.println();
Serial.print("SERVER_UUID:");
if (serverProvisioned) {
@ -271,15 +389,15 @@ void loop() {
} else if (serialBuf.startsWith("SET_KEY:")) {
String hex = serialBuf.substring(8);
hex.toUpperCase();
if (hex.length() == 64 && isHexKey(hex)) {
parseHexKey(hex, hmacKey, HMAC_KEY_LEN);
if (hex.length() == 64 && isHex(hex)) {
parseHex(hex, psk, PSK_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN);
preferences.end();
keyProvisioned = true;
allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned;
pskProvisioned = true;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("HMAC key stored in NVS");
Serial.println("PSK stored in NVS");
if (allProvisioned && !bleReady) {
NimBLEDevice::init("SylDa_Client");
NimBLEScan *pScan = NimBLEDevice::getScan();
@ -298,13 +416,13 @@ void loop() {
} else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7);
hex.toUpperCase();
if (hex.length() == 32 && isHexKey(hex)) {
parseHexKey(hex, deviceUuid, ESP_UUID_LEN);
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, deviceUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
uuidProvisioned = true;
allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("UUID stored in NVS");
} else {
@ -314,13 +432,13 @@ void loop() {
} else if (serialBuf.startsWith("SET_SERVER:")) {
String hex = serialBuf.substring(11);
hex.toUpperCase();
if (hex.length() == 32 && isHexKey(hex)) {
parseHexKey(hex, serverUuid, ESP_UUID_LEN);
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, serverUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end();
serverProvisioned = true;
allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned;
allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK");
Serial.println("Server UUID stored in NVS");
if (allProvisioned && !bleReady) {
@ -364,9 +482,6 @@ void loop() {
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
static bool prevBtnActive = false;
prevBtnActive = btnActive;
bool relay1State = !t1Active || btnActive;
bool relay2State = !t2Active;
@ -406,21 +521,21 @@ void loop() {
leds[0] = ledColor;
FastLED.show();
if (connected && pCmdChar != nullptr) {
if (connected && pCmdChar != nullptr && ecdhReady) {
static uint32_t lastCmd = 0;
if (millis() - lastCmd > 200) {
if (relay1State != relay1SendState) {
uint8_t pkt[HMAC_PKT_SIZE];
buildHmacPacket(relay1State ? 0x01 : 0x00, pkt);
pCmdChar->writeValue(pkt, HMAC_PKT_SIZE, false);
uint8_t pkt[AES_PKT_SIZE];
buildAesPacket(relay1State ? 0x01 : 0x00, pkt);
pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay1SendState = relay1State;
ESP_LOGD(TAG, "TX: relay1 %s (seq=%lu)", relay1State ? "ON" : "OFF", seq);
lastCmd = millis();
}
if (relay2State != relay2SendState) {
uint8_t pkt[HMAC_PKT_SIZE];
buildHmacPacket(relay2State ? 0x02 : 0x03, pkt);
pCmdChar->writeValue(pkt, HMAC_PKT_SIZE, false);
uint8_t pkt[AES_PKT_SIZE];
buildAesPacket(relay2State ? 0x02 : 0x03, pkt);
pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay2SendState = relay2State;
ESP_LOGD(TAG, "TX: relay2 %s (seq=%lu)", relay2State ? "ON" : "OFF", seq);
lastCmd = millis();
@ -456,13 +571,14 @@ void loop() {
static uint32_t heartbeat = 0;
if (millis() - heartbeat > 60000) {
ESP_LOGD(TAG, "[heartbeat] t1=%s t2=%s btn=%s r1=%s r2=%s connected=%s",
ESP_LOGD(TAG, "[heartbeat] t1=%s t2=%s btn=%s r1=%s r2=%s connected=%s ecdh=%s",
!t1Active ? "YES" : "no",
!t2Active ? "YES" : "no",
btnActive ? "YES" : "no",
relay1State ? "ON" : "OFF",
relay2State ? "ON" : "OFF",
connected ? "yes" : "no");
connected ? "yes" : "no",
ecdhReady ? "yes" : "no");
heartbeat = millis();
}

114
src/ecdh_helper.h Normal file
View file

@ -0,0 +1,114 @@
#pragma once
#include <Arduino.h>
#include <mbedtls/ecdh.h>
#include <mbedtls/entropy.h>
#include <mbedtls/ctr_drbg.h>
#include <mbedtls/md.h>
#include <mbedtls/sha256.h>
#define ECDH_PUBKEY_LEN 65
#define AES_KEY_LEN 32
#define PSK_LEN 32
#define AES_PKT_SIZE 29
#define ECDH_PKT_SIZE 97 // pubkey(65) + HMAC(32)
class CryptoECDH {
private:
mbedtls_ecdh_context ecdh;
mbedtls_entropy_context entropy;
mbedtls_ctr_drbg_context ctr_drbg;
bool initialized = false;
CryptoECDH(const CryptoECDH&) = delete;
CryptoECDH& operator=(const CryptoECDH&) = delete;
public:
CryptoECDH() = default;
uint8_t public_key[ECDH_PUBKEY_LEN];
size_t pub_len = 0;
uint8_t shared_secret[32];
uint8_t aes_key[AES_KEY_LEN];
bool init() {
mbedtls_ecdh_init(&ecdh);
mbedtls_entropy_init(&entropy);
mbedtls_ctr_drbg_init(&ctr_drbg);
const char *pers = "sylda_ecdh";
int ret = mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy,
(const unsigned char *)pers, strlen(pers));
if (ret != 0) {
mbedtls_ecdh_free(&ecdh);
mbedtls_entropy_free(&entropy);
mbedtls_ctr_drbg_free(&ctr_drbg);
return false;
}
ret = mbedtls_ecp_group_load(&ecdh.grp, MBEDTLS_ECP_DP_SECP256R1);
if (ret != 0) {
mbedtls_ecdh_free(&ecdh);
mbedtls_entropy_free(&entropy);
mbedtls_ctr_drbg_free(&ctr_drbg);
return false;
}
initialized = true;
return true;
}
bool generateKeys() {
if (!initialized) return false;
int ret = mbedtls_ecdh_gen_public(&ecdh.grp, &ecdh.d, &ecdh.Q,
mbedtls_ctr_drbg_random, &ctr_drbg);
if (ret != 0) return false;
ret = mbedtls_ecp_point_write_binary(&ecdh.grp, &ecdh.Q,
MBEDTLS_ECP_PF_UNCOMPRESSED,
&pub_len, public_key, ECDH_PUBKEY_LEN);
return (ret == 0 && pub_len == ECDH_PUBKEY_LEN);
}
bool computeSharedSecret(const uint8_t *peerPubKey, size_t peerPubLen) {
if (!initialized || peerPubLen != ECDH_PUBKEY_LEN) return false;
int ret = mbedtls_ecp_point_read_binary(&ecdh.grp, &ecdh.Qp,
peerPubKey, peerPubLen);
if (ret != 0) return false;
size_t olen;
ret = mbedtls_ecdh_compute_shared(&ecdh.grp, &ecdh.z, &ecdh.Qp, &ecdh.d,
mbedtls_ctr_drbg_random, &ctr_drbg);
if (ret != 0) return false;
ret = mbedtls_mpi_write_binary(&ecdh.z, shared_secret, 32);
if (ret != 0) return false;
mbedtls_sha256(shared_secret, 32, aes_key, 0);
return true;
}
static void signPubkey(const uint8_t *psk, const uint8_t *pubkey, uint8_t *hmacOut) {
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(mdInfo, psk, PSK_LEN, pubkey, ECDH_PUBKEY_LEN, hmacOut);
}
static bool verifyPubkey(const uint8_t *psk, const uint8_t *pubkey, const uint8_t *hmac) {
uint8_t computed[32];
signPubkey(psk, pubkey, computed);
return memcmp(computed, hmac, 32) == 0;
}
void free() {
if (initialized) {
mbedtls_ecdh_free(&ecdh);
mbedtls_ctr_drbg_free(&ctr_drbg);
mbedtls_entropy_free(&entropy);
memset(shared_secret, 0, sizeof(shared_secret));
memset(aes_key, 0, sizeof(aes_key));
initialized = false;
}
}
};

View file

@ -3,7 +3,7 @@
#include <NimBLEAdvertising.h>
#include <FastLED.h>
#include <mbedtls/md.h>
#include <mbedtls/gcm.h>
#include <mbedtls/sha256.h>
#include <Preferences.h>
#include <esp_ota_ops.h>
@ -11,6 +11,7 @@
#include <nvs_flash.h>
#include <esp_log.h>
#include "version.h"
#include "ecdh_helper.h"
static const char *TAG = "server";
@ -31,8 +32,8 @@ CRGB leds[NUM_LEDS];
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
#define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc"
#define HMAC_PKT_SIZE 37
#define OTA_CHUNK_SIZE 500
#define OTA_CMD_START 0x10
#define OTA_CMD_END 0x11
@ -40,14 +41,14 @@ CRGB leds[NUM_LEDS];
#define OTA_STATUS_NAK 0x01
#define OTA_STATUS_COMPLETE 0x10
#define OTA_STATUS_ERROR 0x11
#define HMAC_KEY_LEN 32
#define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac"
#define NVS_KEY_NAME "key"
#define NVS_NAMESPACE "syl"
#define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid"
#define FIRMWARE_VERSION FIRMWARE_VERSION_SERVER
NimBLECharacteristic *pCharacteristic;
NimBLECharacteristic *pEcdhChar;
bool relay1State = false;
bool relay2State = false;
@ -58,6 +59,19 @@ uint32_t lastSeq[MAX_CLIENTS] = {0, 0};
uint16_t connHandles[MAX_CLIENTS] = {0xFFFF, 0xFFFF};
int connectedCount = 0;
struct ClientState {
CryptoECDH ecdh;
uint8_t aesKey[AES_KEY_LEN];
bool ecdhReady;
};
ClientState clients[MAX_CLIENTS];
uint8_t psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
bool pskProvisioned = false;
bool uuidProvisioned = false;
Preferences preferences;
#define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0;
@ -77,12 +91,6 @@ bool otaEarlyConfirm = false;
uint32_t otaEndedAt = 0;
#define OTA_CONFIRM_TIMEOUT_MS 10000
uint8_t hmacKey[HMAC_KEY_LEN];
uint8_t deviceUuid[ESP_UUID_LEN];
bool keyProvisioned = false;
bool uuidProvisioned = false;
Preferences preferences;
static int findClientIdx(uint16_t connHandle) {
for (int i = 0; i < MAX_CLIENTS; i++) {
if (connActive[i] && connHandles[i] == connHandle) return i;
@ -106,6 +114,9 @@ class MyServerCallbacks : public NimBLEServerCallbacks {
bleRelay1[slot] = false;
bleRelay2[slot] = false;
lastSeq[slot] = 0;
clients[slot].ecdhReady = false;
memset(clients[slot].aesKey, 0, AES_KEY_LEN);
clients[slot].ecdh.free();
connectedCount++;
ESP_LOGI(TAG, "BLE: connected (slot=%d, clients=%d/%d, mac=%s)",
slot, connectedCount, MAX_CLIENTS,
@ -124,6 +135,9 @@ class MyServerCallbacks : public NimBLEServerCallbacks {
bleRelay1[i] = false;
bleRelay2[i] = false;
lastSeq[i] = 0;
clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN);
clients[i].ecdh.free();
connHandles[i] = 0xFFFF;
connectedCount--;
ESP_LOGI(TAG, "BLE: disconnected (slot=%d, clients=%d/%d, mac=%s)",
@ -135,36 +149,47 @@ class MyServerCallbacks : public NimBLEServerCallbacks {
}
};
static bool verifyHmac(const uint8_t *packet, int idx) {
if (idx < 0 || idx >= MAX_CLIENTS || !connActive[idx]) {
ESP_LOGW(TAG, "HMAC verify for invalid idx=%d", idx);
static bool decryptAesGcm(const uint8_t *packet, int idx, uint8_t *outCmd) {
if (idx < 0 || idx >= MAX_CLIENTS || !connActive[idx]) return false;
if (!clients[idx].ecdhReady) {
ESP_LOGW(TAG, "AES: ECDH not done for slot=%d", idx);
return false;
}
uint8_t cmd = packet[0];
uint32_t seq = ((uint32_t)packet[1] << 24) |
((uint32_t)packet[2] << 16) |
((uint32_t)packet[3] << 8) |
(uint32_t)packet[4];
uint32_t seq = ((uint32_t)packet[0] << 24) |
((uint32_t)packet[1] << 16) |
((uint32_t)packet[2] << 8) |
(uint32_t)packet[3];
if (seq <= lastSeq[idx]) {
ESP_LOGW(TAG, "Replay rejected (seq=%lu, last=%lu, slot=%d)", seq, lastSeq[idx], idx);
return false;
}
uint8_t hmacData[5];
hmacData[0] = cmd;
hmacData[1] = (seq >> 24) & 0xFF;
hmacData[2] = (seq >> 16) & 0xFF;
hmacData[3] = (seq >> 8) & 0xFF;
hmacData[4] = seq & 0xFF;
uint8_t nonce[12];
memcpy(nonce, packet, 4);
memcpy(nonce + 4, packet + 4, 8);
uint8_t computed[32];
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(mdInfo, hmacKey, HMAC_KEY_LEN, hmacData, 5, computed);
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, clients[idx].aesKey, AES_KEY_LEN * 8);
if (ret != 0) {
ESP_LOGE(TAG, "AES: setkey failed (slot=%d, ret=%d)", idx, ret);
mbedtls_gcm_free(&gcm);
return false;
}
if (memcmp(computed, packet + 5, 32) != 0) {
ESP_LOGE(TAG, "HMAC mismatch");
ret = mbedtls_gcm_auth_decrypt(&gcm,
1, // ciphertext length
nonce, 12, // iv + iv_len
NULL, 0, // aad + aad_len
packet + 13, 16, // tag + tag_len
packet + 12, // ciphertext
outCmd); // plaintext
mbedtls_gcm_free(&gcm);
if (ret != 0) {
ESP_LOGE(TAG, "AES-GCM decrypt failed (slot=%d, ret=%d)", idx, ret);
return false;
}
@ -172,7 +197,7 @@ static bool verifyHmac(const uint8_t *packet, int idx) {
return true;
}
static bool isHexKey(const String &hex) {
static bool isHex(const String &hex) {
for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false;
@ -180,8 +205,8 @@ static bool isHexKey(const String &hex) {
return true;
}
static void parseHexKey(const String &hex, uint8_t *out) {
for (int i = 0; i < HMAC_KEY_LEN; i++) {
static void parseHex(const String &hex, uint8_t *out, int len) {
for (int i = 0; i < len; i++) {
out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
}
@ -326,6 +351,63 @@ class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
}
};
class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
int idx = findClientIdx(connInfo.getConnHandle());
if (idx < 0) return;
if (!pskProvisioned) {
ESP_LOGW(TAG, "ECDH: write rejected — no PSK (slot=%d)", idx);
return;
}
std::string value = pChar->getValue();
if (value.length() != ECDH_PKT_SIZE) {
ESP_LOGW(TAG, "ECDH: bad packet length %d (expected %d)", (int)value.length(), ECDH_PKT_SIZE);
return;
}
const uint8_t *pkt = (const uint8_t *)value.data();
if (!CryptoECDH::verifyPubkey(psk, pkt, pkt + ECDH_PUBKEY_LEN)) {
ESP_LOGW(TAG, "ECDH: pubkey HMAC mismatch (slot=%d)", idx);
return;
}
uint8_t peerPubkey[ECDH_PUBKEY_LEN];
memcpy(peerPubkey, pkt, ECDH_PUBKEY_LEN);
clients[idx].ecdh.free();
if (!clients[idx].ecdh.init() || !clients[idx].ecdh.generateKeys()) {
ESP_LOGE(TAG, "ECDH: keygen failed (slot=%d)", idx);
return;
}
if (!clients[idx].ecdh.computeSharedSecret(peerPubkey, ECDH_PUBKEY_LEN)) {
ESP_LOGE(TAG, "ECDH: shared secret failed (slot=%d)", idx);
clients[idx].ecdh.free();
return;
}
memcpy(clients[idx].aesKey, clients[idx].ecdh.aes_key, AES_KEY_LEN);
uint8_t resp[ECDH_PKT_SIZE];
memcpy(resp, clients[idx].ecdh.public_key, ECDH_PUBKEY_LEN);
CryptoECDH::signPubkey(psk, clients[idx].ecdh.public_key, resp + ECDH_PUBKEY_LEN);
pChar->setValue(resp, ECDH_PKT_SIZE);
if (!pChar->notify()) {
ESP_LOGW(TAG, "ECDH: notify failed (slot=%d)", idx);
}
clients[idx].ecdhReady = true;
ESP_LOGI(TAG, "ECDH: handshake complete (slot=%d)", idx);
}
};
class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return;
@ -337,19 +419,19 @@ class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
}
std::string value = pChar->getValue();
if (value.length() != HMAC_PKT_SIZE) {
if (value.length() != AES_PKT_SIZE) {
ESP_LOGW(TAG, "BLE: bad packet length %d (expected %d), slot=%d",
(int)value.length(), HMAC_PKT_SIZE, idx);
(int)value.length(), AES_PKT_SIZE, idx);
return;
}
const uint8_t *pkt = (const uint8_t *)value.data();
if (!verifyHmac(pkt, idx)) {
uint8_t cmd;
if (!decryptAesGcm(pkt, idx, &cmd)) {
return;
}
uint8_t cmd = pkt[0];
uint32_t seq = lastSeq[idx];
if (cmd == 0x01) {
@ -376,16 +458,16 @@ void setup() {
FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
preferences.begin(NVS_NAMESPACE, true);
size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN);
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();
if (len == HMAC_KEY_LEN) {
keyProvisioned = true;
ESP_LOGI(TAG, "HMAC key loaded from NVS");
if (pskLen == PSK_LEN) {
pskProvisioned = true;
ESP_LOGI(TAG, "PSK loaded from NVS");
} else {
keyProvisioned = false;
ESP_LOGW(TAG, "No HMAC key — connect USB and run SET_KEY");
pskProvisioned = false;
ESP_LOGW(TAG, "No PSK — connect USB and run SET_KEY");
}
if (uuidLen == ESP_UUID_LEN) {
@ -401,6 +483,11 @@ void setup() {
ESP_LOGD(TAG, "Generated new UUID");
}
for (int i = 0; i < MAX_CLIENTS; i++) {
clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN);
}
pinMode(TOUCH1_PIN, INPUT);
pinMode(TOUCH2_PIN, INPUT);
pinMode(BUTTON_PIN, INPUT);
@ -424,6 +511,12 @@ void setup() {
);
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
pEcdhChar = pService->createCharacteristic(
ECDH_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pEcdhChar->setCallbacks(new EcdhCharacteristicCallbacks());
NimBLECharacteristic *pUuidChar = pService->createCharacteristic(
UUID_CHAR_UUID,
NIMBLE_PROPERTY::READ
@ -442,6 +535,8 @@ void setup() {
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
pService->start();
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("SylDa_Server");
pAdvertising->addServiceUUID(SERVICE_UUID);
@ -456,10 +551,10 @@ void setup() {
bool advOk = pAdvertising->start();
ESP_LOGI(TAG, "BLE: advertising started");
if (keyProvisioned) {
ESP_LOGD(TAG, "HMAC key present");
if (pskProvisioned) {
ESP_LOGD(TAG, "PSK present");
} else {
ESP_LOGW(TAG, "No HMAC key — use USB SET_KEY to provision");
ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision");
}
ESP_LOGI(TAG, "Setup done.");
@ -473,13 +568,13 @@ void loop() {
if (c == '\n') {
serialBuf.trim();
if (serialBuf == "KEY?") {
if (keyProvisioned && uuidProvisioned) {
if (pskProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println();
Serial.print("HMAC_KEY:");
for (int i = 0; i < HMAC_KEY_LEN; i++) {
Serial.printf("%02X", hmacKey[i]);
Serial.print("PSK:");
for (int i = 0; i < PSK_LEN; i++) {
Serial.printf("%02X", psk[i]);
}
Serial.println();
} else {
@ -493,24 +588,22 @@ void loop() {
} else if (serialBuf.startsWith("SET_KEY:")) {
String hex = serialBuf.substring(8);
hex.toUpperCase();
if (hex.length() == 64 && isHexKey(hex)) {
parseHexKey(hex, hmacKey);
if (hex.length() == 64 && isHex(hex)) {
parseHex(hex, psk, PSK_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN);
preferences.end();
keyProvisioned = true;
pskProvisioned = true;
Serial.println("OK");
Serial.println("HMAC key stored in NVS");
Serial.println("PSK stored in NVS");
} else {
Serial.println("ERROR: invalid key format");
}
} else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7);
hex.toUpperCase();
if (hex.length() == 32 && isHexKey(hex)) {
for (int i = 0; i < ESP_UUID_LEN; i++) {
deviceUuid[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
if (hex.length() == 32 && isHex(hex)) {
parseHex(hex, deviceUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end();

View file

@ -1,3 +1,3 @@
// Auto-generated from version.txt — DO NOT EDIT
#define FIRMWARE_VERSION_SERVER "16"
#define FIRMWARE_VERSION_CLIENT "2"
#define FIRMWARE_VERSION_SERVER "18"
#define FIRMWARE_VERSION_CLIENT "4"