Merge branch 'feature/aes-ecdh-ble' into main

- AES-256-GCM + ECDH key exchange vervangt HMAC-SHA256 (issue #2)
- NVS namespace 'hmac' -> 'syl', key 'key' -> 'psk'
- ECDH-UUID characteristic toegevoegd voor key agreement
- Android: PSK key exchange via USB CDC, ECDH handshake in BleRelayManager
- Conflictoplossingen:
  - pUuidChar + pEcdhChar beide globaal, INPUT voor touch pins
  - client.cpp: serverFound + MyScanCallbacks behouden voor ECDH compat
  - SettingsActivity.kt: PSK detectie + debug log gecombineerd
  - version.txt: HEAD versie (20/5) behouden
  - version.h: verwijderd (auto-gegenereerd)
This commit is contained in:
eddy 2026-08-03 23:45:15 +02:00
commit a242bfa58a
9 changed files with 797 additions and 171 deletions

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,15 +41,15 @@ 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 *pUuidChar;
NimBLECharacteristic *pEcdhChar;
bool relay1State = false;
bool relay2State = false;
@ -59,6 +60,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;
@ -78,12 +92,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;
@ -107,6 +115,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,
@ -125,6 +136,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)",
@ -136,36 +150,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;
}
@ -173,7 +198,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;
@ -181,8 +206,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);
}
}
@ -327,6 +352,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;
@ -338,19 +420,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) {
@ -377,16 +459,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) {
@ -402,8 +484,13 @@ void setup() {
ESP_LOGD(TAG, "Generated new UUID");
}
pinMode(TOUCH1_PIN, INPUT_PULLUP);
pinMode(TOUCH2_PIN, INPUT_PULLUP);
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);
pinMode(RELAY1_PIN, OUTPUT);
pinMode(RELAY2_PIN, OUTPUT);
@ -425,6 +512,12 @@ void setup() {
);
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
pEcdhChar = pService->createCharacteristic(
ECDH_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pEcdhChar->setCallbacks(new EcdhCharacteristicCallbacks());
pUuidChar = pService->createCharacteristic(
UUID_CHAR_UUID,
NIMBLE_PROPERTY::READ
@ -443,6 +536,8 @@ void setup() {
);
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
pService->start();
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("SylDa_Server");
@ -463,10 +558,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.");
@ -480,13 +575,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 {
@ -501,24 +596,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();