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

@ -21,8 +21,15 @@ import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.security.KeyPairGenerator
import java.security.SecureRandom
import java.security.spec.ECGenParameterSpec
import java.security.interfaces.ECPublicKey
import java.util.UUID import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyAgreement
import javax.crypto.Mac import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlin.text.Charsets import kotlin.text.Charsets
@ -38,30 +45,50 @@ class BleRelayManager(
private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9") private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9")
private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db") private val VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db")
private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da") private val OTA_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26da")
private val ECDH_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dc")
private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val SCAN_TIMEOUT_MS = 30000L private const val SCAN_TIMEOUT_MS = 30000L
private const val RECONNECT_DELAY_MS = 2000L private const val RECONNECT_DELAY_MS = 2000L
private const val MAX_SCAN_RETRIES = 3 private const val MAX_SCAN_RETRIES = 3
private const val HMAC_PKT_SIZE = 37 private const val AES_PKT_SIZE = 29
private const val HMAC_LEN = 32 private const val GCM_TAG_LEN = 16
private const val GCM_NONCE_LEN = 12
private const val AES_KEY_LEN = 32
private const val PSK_LEN = 32
private const val ECDH_PUBKEY_LEN = 65
private const val ECDH_PKT_SIZE = 97
private const val BLE_MTU = 512 private const val BLE_MTU = 512
private const val ECDH_TIMEOUT_MS = 5000L
} }
private var bluetoothGatt: BluetoothGatt? = null private var bluetoothGatt: BluetoothGatt? = null
private var relayCharacteristic: BluetoothGattCharacteristic? = null private var relayCharacteristic: BluetoothGattCharacteristic? = null
private var ecdhCharacteristic: BluetoothGattCharacteristic? = null
private var isScanning = false private var isScanning = false
private var scanTimedOut = false private var scanTimedOut = false
private var scanTimeoutRetries = 0 private var scanTimeoutRetries = 0
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var seq = 1 private var seq = 1
private var hmacKey: ByteArray? = null private var psk: ByteArray? = null
@Volatile private var aesKey: ByteArray? = null
private var deviceUuid: ByteArray? = null private var deviceUuid: ByteArray? = null
private var triedAddresses = mutableSetOf<String>() private var triedAddresses = mutableSetOf<String>()
private var otaCharacteristic: BluetoothGattCharacteristic? = null private var otaCharacteristic: BluetoothGattCharacteristic? = null
private var espVersion: String? = null private var espVersion: String? = null
private var appVersion: String = "" private var appVersion: String = ""
private var negotiatedMtu: Int = 23 private var negotiatedMtu: Int = 23
private var ecdhDone = false
@Volatile private var ecdhDeferred: CompletableDeferred<Boolean>? = null
private var ecdhNotifyBuf: ByteArray = ByteArray(0)
private class CompletableDeferred<T> {
private var result: T? = null
private var completed = false
@Synchronized fun complete(value: T) { result = value; completed = true }
@Synchronized fun isDone(): Boolean = completed
@Synchronized fun get(): T? = result
}
interface OtaNotifyCallback { interface OtaNotifyCallback {
fun onOtaNotify(status: Int, seq: Int) fun onOtaNotify(status: Int, seq: Int)
@ -107,8 +134,8 @@ class BleRelayManager(
return return
} }
loadState() loadState()
if (hmacKey == null || deviceUuid == null) { if (psk == null || deviceUuid == null) {
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
return return
} }
isScanning = true isScanning = true
@ -175,7 +202,7 @@ class BleRelayManager(
fun sendRelayCommand(cmd: Byte) { fun sendRelayCommand(cmd: Byte) {
val char = relayCharacteristic ?: return val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return val gatt = bluetoothGatt ?: return
val key = hmacKey ?: return val key = aesKey ?: return
val packet = buildPacket(cmd, key) val packet = buildPacket(cmd, key)
char.value = packet char.value = packet
@ -256,9 +283,9 @@ class BleRelayManager(
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) { if (newState == BluetoothProfile.STATE_CONNECTED) {
if (hmacKey == null) { if (psk == null) {
Log.w(TAG, "GATT connected but no key -- disconnecting") Log.w(TAG, "GATT connected but no PSK -- disconnecting")
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
gatt.disconnect() gatt.disconnect()
return return
} }
@ -271,6 +298,9 @@ class BleRelayManager(
gatt.close() gatt.close()
bluetoothGatt = null bluetoothGatt = null
relayCharacteristic = null relayCharacteristic = null
ecdhCharacteristic = null
aesKey = null
ecdhDone = false
seq = 1 seq = 1
triedAddresses.clear() triedAddresses.clear()
handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS) handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS)
@ -296,6 +326,7 @@ class BleRelayManager(
if (service != null) { if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
otaCharacteristic = service.getCharacteristic(OTA_UUID) otaCharacteristic = service.getCharacteristic(OTA_UUID)
ecdhCharacteristic = service.getCharacteristic(ECDH_UUID)
val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) val uuidChar = service.getCharacteristic(UUID_CHAR_UUID)
val versionChar = service.getCharacteristic(VERSION_UUID) val versionChar = service.getCharacteristic(VERSION_UUID)
if (relayCharacteristic != null && uuidChar != null) { if (relayCharacteristic != null && uuidChar != null) {
@ -316,21 +347,8 @@ class BleRelayManager(
if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) { if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) {
val storedUuid = deviceUuid val storedUuid = deviceUuid
if (storedUuid != null && value.contentEquals(storedUuid)) { if (storedUuid != null && value.contentEquals(storedUuid)) {
Log.d(TAG, "UUID verified, reading version...") Log.d(TAG, "UUID verified, starting ECDH handshake...")
val service = gatt.getService(SERVICE_UUID) startEcdhHandshake(gatt)
val versionChar = service?.getCharacteristic(VERSION_UUID)
if (versionChar != null) {
val ok = gatt.readCharacteristic(versionChar)
if (!ok) {
Log.w(TAG, "readCharacteristic(VERSION) returned false, treating as no version")
handler.post { callbacks.onFirmwareVersion("", appVersion) }
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
}
} else {
Log.d(TAG, "VERSION characteristic not found on ESP (old firmware)")
handler.post { callbacks.onFirmwareVersion("", appVersion) }
handler.post { callbacks.onStatusChanged(BleStatus.Connected) }
}
} else { } else {
Log.w(TAG, "UUID mismatch -- disconnecting") Log.w(TAG, "UUID mismatch -- disconnecting")
triedAddresses.add(gatt.device.address) triedAddresses.add(gatt.device.address)
@ -366,35 +384,185 @@ class BleRelayManager(
value: ByteArray value: ByteArray
) { ) {
if (characteristic.uuid == OTA_UUID && value.size >= 1) { if (characteristic.uuid == OTA_UUID && value.size >= 1) {
val status = value[0].toInt() and 0xFF val statusVal = value[0].toInt() and 0xFF
val seq = if (value.size >= 5) { val seqVal = if (value.size >= 5) {
((value[1].toInt() and 0xFF) shl 24) or ((value[1].toInt() and 0xFF) shl 24) or
((value[2].toInt() and 0xFF) shl 16) or ((value[2].toInt() and 0xFF) shl 16) or
((value[3].toInt() and 0xFF) shl 8) or ((value[3].toInt() and 0xFF) shl 8) or
(value[4].toInt() and 0xFF) (value[4].toInt() and 0xFF)
} else 0 } else 0
val statusName = when (status) { 0x00 -> "ACK"; 0x01 -> "NAK"; 0x10 -> "COMPLETE"; 0x11 -> "ERROR"; else -> "UNKNOWN(0x${status.toString(16)})" } val statusName = when (statusVal) { 0x00 -> "ACK"; 0x01 -> "NAK"; 0x10 -> "COMPLETE"; 0x11 -> "ERROR"; else -> "UNKNOWN(0x${statusVal.toString(16)})" }
Log.d(TAG, "OTA notify: status=$statusName seq=$seq raw=${value.joinToString("") { "%02X".format(it) }}") Log.d(TAG, "OTA notify: status=$statusName seq=$seqVal raw=${value.joinToString("") { "%02X".format(it) }}")
handler.post { otaNotifyCallback?.onOtaNotify(status, seq) } handler.post { otaNotifyCallback?.onOtaNotify(statusVal, seqVal) }
} else if (characteristic.uuid == ECDH_UUID) {
ecdhNotifyBuf = value.copyOf()
ecdhDeferred?.complete(true)
Log.d(TAG, "ECDH notify received (${value.size} bytes)")
} }
} }
} }
private fun loadState() { @SuppressLint("MissingPermission")
val prefs = PrefsHelper.getPrefs(context) private fun startEcdhHandshake(gatt: BluetoothGatt) {
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) val char = ecdhCharacteristic
hmacKey = hexKey?.let { hexStringToBytes(it) } if (char == null) {
val hexUuid = PrefsHelper.getServerUuid(context) Log.w(TAG, "ECDH characteristic not found")
?: prefs.getString(PrefsHelper.PREF_UUID, null) handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
deviceUuid = hexUuid?.let { hexStringToBytes(it) } gatt.disconnect()
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX) return
Log.d(TAG, "HMAC key loaded: ${hmacKey?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq") }
if (!gatt.setCharacteristicNotification(char, true)) {
Log.w(TAG, "ECDH: setCharacteristicNotification failed")
gatt.disconnect()
return
}
val cccd = char.getDescriptor(OTA_CCCD_UUID)
if (cccd != null) {
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(cccd)
}
val myPsk = psk ?: run {
Log.w(TAG, "ECDH: no PSK")
gatt.disconnect()
return
}
try {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val keyPair = kpg.generateKeyPair()
val pubKey = keyPair.public as ECPublicKey
val pubBytes = encodeEcPubKey(pubKey)
if (pubBytes.size != ECDH_PUBKEY_LEN) {
Log.e(TAG, "ECDH: pubkey wrong size ${pubBytes.size}")
gatt.disconnect()
return
}
val handshake = ByteArray(ECDH_PKT_SIZE)
System.arraycopy(pubBytes, 0, handshake, 0, ECDH_PUBKEY_LEN)
val sig = hmacSha256(pubBytes, myPsk)
System.arraycopy(sig, 0, handshake, ECDH_PUBKEY_LEN, 32)
ecdhDeferred = CompletableDeferred()
char.value = handshake
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
if (!gatt.writeCharacteristic(char)) {
Log.w(TAG, "ECDH: write pubkey failed")
gatt.disconnect()
return
}
Log.d(TAG, "ECDH: pubkey written, waiting for server...")
Thread {
val start = System.currentTimeMillis()
while (!ecdhDeferred!!.isDone() && System.currentTimeMillis() - start < ECDH_TIMEOUT_MS) {
Thread.sleep(50)
}
if (!ecdhDeferred!!.isDone()) {
Log.w(TAG, "ECDH: notify timeout")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val resp = ecdhNotifyBuf
if (resp.size != ECDH_PKT_SIZE) {
Log.w(TAG, "ECDH: bad response size ${resp.size}")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val serverPub = resp.copyOfRange(0, ECDH_PUBKEY_LEN)
val serverSig = resp.copyOfRange(ECDH_PUBKEY_LEN, ECDH_PKT_SIZE)
val expectedSig = hmacSha256(serverPub, myPsk)
if (!serverSig.contentEquals(expectedSig)) {
Log.w(TAG, "ECDH: server pubkey HMAC mismatch")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val ka = KeyAgreement.getInstance("ECDH")
ka.init(keyPair.private)
val spec = java.security.spec.ECPublicKeySpec(
java.security.spec.ECPoint(
java.math.BigInteger(1, serverPub.copyOfRange(1, 33)),
java.math.BigInteger(1, serverPub.copyOfRange(33, 65))
),
java.security.spec.ECParameterSpec(
java.security.spec.EllipticCurve(
java.security.spec.ECFieldFp(
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", 16)
),
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC", 16),
java.math.BigInteger("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B", 16)
),
java.security.spec.ECPoint(
java.math.BigInteger("6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296", 16),
java.math.BigInteger("4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5", 16)
),
java.math.BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16),
1
)
)
val serverKey = java.security.KeyFactory.getInstance("EC").generatePublic(spec)
ka.doPhase(serverKey, true)
val shared = ka.generateSecret()
val md = java.security.MessageDigest.getInstance("SHA-256")
aesKey = md.digest(shared)
ecdhDone = true
seq = 1
handler.post {
Log.d(TAG, "ECDH: handshake complete, reading version...")
val service = gatt.getService(SERVICE_UUID)
val versionChar = service?.getCharacteristic(VERSION_UUID)
if (versionChar != null) {
val ok = gatt.readCharacteristic(versionChar)
if (!ok) {
Log.w(TAG, "readCharacteristic(VERSION) returned false")
callbacks.onFirmwareVersion("", appVersion)
callbacks.onStatusChanged(BleStatus.Connected)
}
} else {
Log.d(TAG, "VERSION characteristic not found on ESP")
callbacks.onFirmwareVersion("", appVersion)
callbacks.onStatusChanged(BleStatus.Connected)
}
}
}.start()
} catch (e: Exception) {
Log.e(TAG, "ECDH: exception", e)
gatt.disconnect()
}
} }
private fun hexStringToBytes(hex: String): ByteArray? { private fun encodeEcPubKey(pubKey: ECPublicKey): ByteArray {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" } val x = pubKey.w.affineX.toByteArray()
if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null val y = pubKey.w.affineY.toByteArray()
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray() val xPadded = ByteArray(32)
val yPadded = ByteArray(32)
System.arraycopy(x, maxOf(0, x.size - 32), xPadded, maxOf(0, 32 - x.size), minOf(x.size, 32))
System.arraycopy(y, maxOf(0, y.size - 32), yPadded, maxOf(0, 32 - y.size), minOf(y.size, 32))
val out = ByteArray(65)
out[0] = 0x04
System.arraycopy(xPadded, 0, out, 1, 32)
System.arraycopy(yPadded, 0, out, 33, 32)
return out
} }
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray { private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
@ -403,16 +571,43 @@ class BleRelayManager(
return mac.doFinal(data) return mac.doFinal(data)
} }
private fun loadState() {
val prefs = PrefsHelper.getPrefs(context)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
psk = hexKey?.let { hexStringToBytes(it) }
val hexUuid = PrefsHelper.getServerUuid(context)
?: prefs.getString(PrefsHelper.PREF_UUID, null)
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
Log.d(TAG, "PSK loaded: ${psk?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq")
}
private fun hexStringToBytes(hex: String): ByteArray? {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray { private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray {
val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array() val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array()
val hmacData = ByteArray(5) val nonceRandom = ByteArray(8)
hmacData[0] = cmd SecureRandom().nextBytes(nonceRandom)
System.arraycopy(seqBytes, 0, hmacData, 1, 4)
val hmac = hmacSha256(hmacData, key) val nonce = ByteArray(GCM_NONCE_LEN)
val packet = ByteArray(HMAC_PKT_SIZE) System.arraycopy(seqBytes, 0, nonce, 0, 4)
packet[0] = cmd System.arraycopy(nonceRandom, 0, nonce, 4, 8)
System.arraycopy(seqBytes, 0, packet, 1, 4)
System.arraycopy(hmac, 0, packet, 5, HMAC_LEN) val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE,
SecretKeySpec(key, "AES"),
GCMParameterSpec(GCM_TAG_LEN * 8, nonce))
val cipherOutput = cipher.doFinal(byteArrayOf(cmd))
val packet = ByteArray(AES_PKT_SIZE)
System.arraycopy(seqBytes, 0, packet, 0, 4)
System.arraycopy(nonceRandom, 0, packet, 4, 8)
System.arraycopy(cipherOutput, 0, packet, 12, cipherOutput.size)
return packet return packet
} }
} }

View file

@ -9,7 +9,7 @@ object PrefsHelper {
private const val TAG = "PrefsHelper" private const val TAG = "PrefsHelper"
const val PREFS_NAME = "relay_prefs" const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key" const val PREF_KEY = "psk"
const val PREF_UUID = "device_uuid" const val PREF_UUID = "device_uuid"
const val PREF_SEQ = "seq_counter" const val PREF_SEQ = "seq_counter"
const val PREF_THEME = "theme" const val PREF_THEME = "theme"

View file

@ -56,10 +56,10 @@ class SettingsActivity : AppCompatActivity() {
private const val STYLE_SPINNER = 0 private const val STYLE_SPINNER = 0
private const val STYLE_HORIZONTAL = 1 private const val STYLE_HORIZONTAL = 1
private const val USB_READ_BUF_SIZE = 512 private const val USB_READ_BUF_SIZE = 512
private const val HMAC_HEX_LEN = 64 private const val PSK_HEX_LEN = 64
private const val UUID_HEX_LEN = 32 private const val UUID_HEX_LEN = 32
private const val UUID_BYTE_COUNT = 16 private const val UUID_BYTE_COUNT = 16
private const val HMAC_BYTE_COUNT = 32 private const val PSK_BYTE_COUNT = 32
private const val VERSION_TIMEOUT_MS = 3000L private const val VERSION_TIMEOUT_MS = 3000L
private const val REBOOT_WAIT_MS = 5000L private const val REBOOT_WAIT_MS = 5000L
private const val REBOOT_POLL_MS = 500L private const val REBOOT_POLL_MS = 500L
@ -642,16 +642,16 @@ class SettingsActivity : AppCompatActivity() {
Thread.sleep(RESPONSE_WAIT_MS) Thread.sleep(RESPONSE_WAIT_MS)
val response = readResponse(connection, epIn, KEY_TIMEOUT_MS) val response = readResponse(connection, epIn, KEY_TIMEOUT_MS)
Log.d(TAG, "Attempt $attempt response: ${response.take(300)}") Log.d(TAG, "Attempt $attempt: got ${response.lines().size} line(s)")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") } val keyLine = response.lines().firstOrNull { it.startsWith("PSK:") }
val uuidLine = response.lines().firstOrNull { it.startsWith("DEVICE_ID:") } val uuidLine = response.lines().firstOrNull { it.startsWith("DEVICE_ID:") }
val noKey = response.lines().any { it.trim() == "NO_KEY" } val noKey = response.lines().any { it.trim() == "NO_KEY" }
if (keyLine != null && uuidLine != null) { if (keyLine != null && uuidLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim() val hexKey = keyLine.removePrefix("PSK:").trim()
val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim() val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim()
if (hexKey.length == HMAC_HEX_LEN && hexKey.all { it in "0123456789ABCDEFabcdef" } && if (hexKey.length == PSK_HEX_LEN && hexKey.all { it in "0123456789ABCDEFabcdef" } &&
hexUuid.length == UUID_HEX_LEN && hexUuid.all { it in "0123456789ABCDEFabcdef" }) { hexUuid.length == UUID_HEX_LEN && hexUuid.all { it in "0123456789ABCDEFabcdef" }) {
val espKeyUpper = hexKey.uppercase() val espKeyUpper = hexKey.uppercase()
val espUuidUpper = hexUuid.uppercase() val espUuidUpper = hexUuid.uppercase()
@ -666,7 +666,7 @@ class SettingsActivity : AppCompatActivity() {
val newKey: String val newKey: String
val newUuid: String val newUuid: String
if (appKey != null) { if (appKey != null) {
Log.d(TAG, "ESP has no keys, using existing HMAC key") Log.d(TAG, "ESP has no keys, using existing PSK")
newKey = appKey newKey = appKey
newUuid = if (PrefsHelper.getDeviceMode(this) == "server") { newUuid = if (PrefsHelper.getDeviceMode(this) == "server") {
generateRandomHex(UUID_BYTE_COUNT) generateRandomHex(UUID_BYTE_COUNT)
@ -757,7 +757,7 @@ class SettingsActivity : AppCompatActivity() {
val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS) val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS)
if (len > 0) { if (len > 0) {
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII)) sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) { if ((sb.contains("PSK:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) {
break break
} }
} }
@ -802,7 +802,7 @@ class SettingsActivity : AppCompatActivity() {
private fun generateRandomHexKey(): String { private fun generateRandomHexKey(): String {
val random = SecureRandom() val random = SecureRandom()
val bytes = ByteArray(HMAC_BYTE_COUNT) val bytes = ByteArray(PSK_BYTE_COUNT)
random.nextBytes(bytes) random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) } return bytes.joinToString("") { "%02X".format(it) }
} }

View file

@ -0,0 +1,63 @@
# Plan: Issue #2 — AES-256-GCM + ECDH + PSK voor BLE relay commands
## Beveiligingsmodel
### Provisioning (eenmalig, USB CDC)
Android genereert 32B PSK (pre-shared key) en provisioned beide ESP's:
- `SET_KEY` → Server ESP (NVS `syl/psk`)
- `SET_KEY` → Client ESP (NVS `syl/psk`)
### BLE ECDH handshake (elke sessie)
```
Client Server
|── pubkey(65B) || HMAC(PSK,pubkey)(32B) ─────>| verify HMAC met PSK
| | genereer eigen keypair
|<─ pubkey(65B) || HMAC(PSK,pubkey)(32B) ──────| verify HMAC met PSK
| |
AES_KEY = SHA-256(ECDH_shared_secret) AES_KEY = SHA-256(ECDH_shared_secret)
```
- PSK verlaat nooit het apparaat over BLE — alleen HMAC ervan
- Zonder PSK kan MITM geen geldige HMAC voor fake pubkey produceren
- Per-sessie AES keys → perfect forward secrecy
- PSK gelekt? → her-provisionen via USB CDC
## Packet structuren
### ECDH handshake packet (97 bytes)
```
pubkey[65] || HMAC-SHA256(PSK, pubkey)[32]
```
BLE characteristic: `beb5483e-...-f26dc`, properties `WRITE_NR | NOTIFY`
### Relay command (29 bytes, AES-256-GCM)
```
seq(4) || nonce(8) || ciphertext_cmd(1) || tag(16)
```
Nonce voor GCM = `seq(4) || random(8)` = 12 bytes.
Sequence > lastSeq voor replay protection per sessie.
## NVS / SharedPreferences
| Opslag | Sleutel | Waarde |
|---|---|---|
| ESP NVS `syl` | `psk` | 32B PSK |
| ESP NVS `syl` | `uuid` | 16B device UUID |
| Android Prefs | `psk` | 64-char hex PSK |
| Android Prefs | `device_uuid` | 32-char hex UUID |
## Bestandswijzigingen
### Nieuw
- `src/ecdh_helper.h` — ECDH keypair generatie + shared secret (mbedtls/ecdh.h)
### Gewijzigd
- `src/main.cpp` — HMAC → ECDH handshake + AES-GCM decrypt; PSK uit NVS
- `src/client.cpp` — HMAC → ECDH handshake + AES-GCM encrypt; PSK uit NVS
- `android/.../BleRelayManager.kt` — HMAC → ECDH (KeyAgreement) + AES-GCM (Cipher)
- `android/.../SettingsActivity.kt` — SET_KEY schrijft voortaan PSK
- `android/.../PrefsHelper.kt` — `hmac_key` → `psk`
- `version.txt` — bump server + client versies
## Werkt NIET met oude firmware/app
Geen backward compatibility: schone break. HMAC packets worden niet meer geaccepteerd.

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

34
icon.svg Normal file
View file

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="12" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<!-- Achtergrond: Dark Antraciet Squircle -->
<rect x="32" y="32" width="448" height="448" rx="105" ry="105" fill="#121212" />
<rect x="32" y="32" width="448" height="448" rx="105" ry="105" fill="none" stroke="#2A2A32" stroke-width="4" />
<!-- Centrale Icon Groep met Gloed -->
<g filter="url(#glow)">
<!-- Power Cirkel (geopend aan de bovenkant) -->
<path d="M 186 210 A 110 110 0 1 0 326 210"
fill="none"
stroke="#00E5FF"
stroke-width="28"
stroke-linecap="round" />
<!-- Bluetooth Pijl / Power Pin -->
<path d="M 256 120 L 256 260 M 256 120 L 285 145 L 240 180 L 270 205 L 256 216"
fill="none"
stroke="#00E5FF"
stroke-width="24"
stroke-linecap="round"
stroke-linejoin="round" />
</g>
<!-- Twee status-stippen voor Relais 1 & 2 -->
<circle cx="216" cy="370" r="10" fill="#00E5FF" opacity="0.8" />
<circle cx="296" cy="370" r="10" fill="#00E5FF" opacity="0.8" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -2,12 +2,13 @@
#include <NimBLEDevice.h> #include <NimBLEDevice.h>
#include <FastLED.h> #include <FastLED.h>
#include <mbedtls/md.h> #include <mbedtls/gcm.h>
#include <mbedtls/sha256.h> #include <mbedtls/sha256.h>
#include <Preferences.h> #include <Preferences.h>
#include <esp_log.h> #include <esp_log.h>
#include "version.h" #include "version.h"
#include "ecdh_helper.h"
static const char *TAG = "client"; static const char *TAG = "client";
@ -22,21 +23,20 @@ CRGB leds[NUM_LEDS];
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8" #define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9" #define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define HMAC_PKT_SIZE 37 #define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc"
#define HMAC_KEY_LEN 32
#define ESP_UUID_LEN 16 #define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac" #define NVS_NAMESPACE "syl"
#define NVS_KEY_NAME "key" #define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid" #define NVS_UUID_NAME "uuid"
#define NVS_SERVER_NAME "server" #define NVS_SERVER_NAME "server"
#define DEBOUNCE_MS 50 #define DEBOUNCE_MS 50
#define SCAN_INTERVAL 6000 #define SCAN_INTERVAL 6000
#define SCAN_TIMEOUT 5000 #define SCAN_TIMEOUT 5000
uint8_t hmacKey[HMAC_KEY_LEN]; uint8_t psk[PSK_LEN];
uint8_t deviceUuid[ESP_UUID_LEN]; uint8_t deviceUuid[ESP_UUID_LEN];
uint8_t serverUuid[ESP_UUID_LEN]; uint8_t serverUuid[ESP_UUID_LEN];
bool keyProvisioned = false; bool pskProvisioned = false;
bool uuidProvisioned = false; bool uuidProvisioned = false;
bool serverProvisioned = false; bool serverProvisioned = false;
bool allProvisioned = false; bool allProvisioned = false;
@ -50,6 +50,9 @@ static bool relay1SendState = false;
static bool relay2SendState = false; static bool relay2SendState = false;
static uint32_t seq = 0; static uint32_t seq = 0;
static uint8_t aesKey[AES_KEY_LEN];
static bool ecdhReady = false;
#define STABLE_LED_TIMEOUT_MS 60000 #define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0; uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0; uint32_t relayFlashUntil = 0;
@ -60,6 +63,10 @@ static bool bleReady = false;
static bool serverFound = false; static bool serverFound = false;
static NimBLEAddress serverAddress; static NimBLEAddress serverAddress;
static bool ecdhNotifyReceived = false;
static uint8_t ecdhNotifyBuf[ECDH_PKT_SIZE];
static size_t ecdhNotifyLen = 0;
class MyScanCallbacks : public NimBLEScanCallbacks { class MyScanCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override { void onResult(const NimBLEAdvertisedDevice *advertisedDevice) override {
if (advertisedDevice->isAdvertisingService(NimBLEUUID(SERVICE_UUID))) { if (advertisedDevice->isAdvertisingService(NimBLEUUID(SERVICE_UUID))) {
@ -80,13 +87,15 @@ class MyClientCallbacks : public NimBLEClientCallbacks {
relay1SendState = false; relay1SendState = false;
relay2SendState = false; relay2SendState = false;
pCmdChar = nullptr; pCmdChar = nullptr;
ecdhReady = false;
memset(aesKey, 0, AES_KEY_LEN);
ESP_LOGW(TAG, "BLE: disconnected (reason=%d)", reason); ESP_LOGW(TAG, "BLE: disconnected (reason=%d)", reason);
} }
}; };
static MyClientCallbacks clientCallbacks; static MyClientCallbacks clientCallbacks;
static bool isHexKey(const String &hex) { static bool isHex(const String &hex) {
for (int i = 0; i < hex.length(); i++) { for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i); char c = hex.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false; if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false;
@ -94,24 +103,41 @@ static bool isHexKey(const String &hex) {
return true; 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++) { for (int i = 0; i < len; i++) {
out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16); 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; uint32_t s = ++seq;
packet[0] = cmd; packet[0] = (s >> 24) & 0xFF;
packet[1] = (s >> 24) & 0xFF; packet[1] = (s >> 16) & 0xFF;
packet[2] = (s >> 16) & 0xFF; packet[2] = (s >> 8) & 0xFF;
packet[3] = (s >> 8) & 0xFF; packet[3] = s & 0xFF;
packet[4] = s & 0xFF;
uint8_t hmacData[5]; uint8_t nonceRandom[8];
hmacData[0] = cmd; for (int i = 0; i < 8; i++) nonceRandom[i] = (uint8_t)(esp_random() & 0xFF);
memcpy(hmacData + 1, packet + 1, 4); memcpy(packet + 4, nonceRandom, 8);
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); 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() { static void disconnectClient() {
@ -125,6 +151,89 @@ static void disconnectClient() {
connected = false; connected = false;
relay1SendState = false; relay1SendState = false;
relay2SendState = 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() { static bool connectToServer() {
@ -167,10 +276,19 @@ static bool connectToServer() {
return false; 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; seq = 0;
relay1SendState = false; relay1SendState = false;
relay2SendState = false; relay2SendState = false;
ESP_LOGI(TAG, "BLE: server UUID verified, ready"); ESP_LOGI(TAG, "BLE: server UUID verified, ECDH done, ready");
return true; return true;
} }
@ -184,17 +302,17 @@ void setup() {
FastLED.show(); FastLED.show();
preferences.begin(NVS_NAMESPACE, true); 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 uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
size_t serverLen = preferences.getBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN); size_t serverLen = preferences.getBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end(); preferences.end();
if (len == HMAC_KEY_LEN) { if (pskLen == PSK_LEN) {
keyProvisioned = true; pskProvisioned = true;
ESP_LOGI(TAG, "HMAC key loaded from NVS"); ESP_LOGI(TAG, "PSK loaded from NVS");
} else { } else {
keyProvisioned = false; pskProvisioned = false;
ESP_LOGW(TAG, "No HMAC key — use USB SET_KEY to provision"); ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision");
} }
if (uuidLen == ESP_UUID_LEN) { if (uuidLen == ESP_UUID_LEN) {
@ -218,7 +336,7 @@ void setup() {
ESP_LOGW(TAG, "No server UUID — use USB SET_SERVER to provision"); 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(TOUCH1_PIN, INPUT);
pinMode(TOUCH2_PIN, INPUT); pinMode(TOUCH2_PIN, INPUT);
@ -248,12 +366,12 @@ void loop() {
if (c == '\n') { if (c == '\n') {
serialBuf.trim(); serialBuf.trim();
if (serialBuf == "KEY?") { if (serialBuf == "KEY?") {
if (keyProvisioned && uuidProvisioned) { if (pskProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:"); Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]); for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println(); Serial.println();
Serial.print("HMAC_KEY:"); Serial.print("PSK:");
for (int i = 0; i < HMAC_KEY_LEN; i++) Serial.printf("%02X", hmacKey[i]); for (int i = 0; i < PSK_LEN; i++) Serial.printf("%02X", psk[i]);
Serial.println(); Serial.println();
Serial.print("SERVER_UUID:"); Serial.print("SERVER_UUID:");
if (serverProvisioned) { if (serverProvisioned) {
@ -271,15 +389,15 @@ void loop() {
} else if (serialBuf.startsWith("SET_KEY:")) { } else if (serialBuf.startsWith("SET_KEY:")) {
String hex = serialBuf.substring(8); String hex = serialBuf.substring(8);
hex.toUpperCase(); hex.toUpperCase();
if (hex.length() == 64 && isHexKey(hex)) { if (hex.length() == 64 && isHex(hex)) {
parseHexKey(hex, hmacKey, HMAC_KEY_LEN); parseHex(hex, psk, PSK_LEN);
preferences.begin(NVS_NAMESPACE, false); preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN); preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN);
preferences.end(); preferences.end();
keyProvisioned = true; pskProvisioned = true;
allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned; allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK"); Serial.println("OK");
Serial.println("HMAC key stored in NVS"); Serial.println("PSK stored in NVS");
if (allProvisioned && !bleReady) { if (allProvisioned && !bleReady) {
NimBLEDevice::init("SylDa_Client"); NimBLEDevice::init("SylDa_Client");
NimBLEScan *pScan = NimBLEDevice::getScan(); NimBLEScan *pScan = NimBLEDevice::getScan();
@ -298,13 +416,13 @@ void loop() {
} else if (serialBuf.startsWith("SET_ID:")) { } else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7); String hex = serialBuf.substring(7);
hex.toUpperCase(); hex.toUpperCase();
if (hex.length() == 32 && isHexKey(hex)) { if (hex.length() == 32 && isHex(hex)) {
parseHexKey(hex, deviceUuid, ESP_UUID_LEN); parseHex(hex, deviceUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false); preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN); preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end(); preferences.end();
uuidProvisioned = true; uuidProvisioned = true;
allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned; allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK"); Serial.println("OK");
Serial.println("UUID stored in NVS"); Serial.println("UUID stored in NVS");
} else { } else {
@ -314,13 +432,13 @@ void loop() {
} else if (serialBuf.startsWith("SET_SERVER:")) { } else if (serialBuf.startsWith("SET_SERVER:")) {
String hex = serialBuf.substring(11); String hex = serialBuf.substring(11);
hex.toUpperCase(); hex.toUpperCase();
if (hex.length() == 32 && isHexKey(hex)) { if (hex.length() == 32 && isHex(hex)) {
parseHexKey(hex, serverUuid, ESP_UUID_LEN); parseHex(hex, serverUuid, ESP_UUID_LEN);
preferences.begin(NVS_NAMESPACE, false); preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN); preferences.putBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN);
preferences.end(); preferences.end();
serverProvisioned = true; serverProvisioned = true;
allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned; allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned;
Serial.println("OK"); Serial.println("OK");
Serial.println("Server UUID stored in NVS"); Serial.println("Server UUID stored in NVS");
if (allProvisioned && !bleReady) { if (allProvisioned && !bleReady) {
@ -364,9 +482,6 @@ void loop() {
bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable; bool t2Active = (millis() - t2Change > DEBOUNCE_MS) && t2Stable;
bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable; bool btnActive = (millis() - btnChange > DEBOUNCE_MS) && btnStable;
static bool prevBtnActive = false;
prevBtnActive = btnActive;
bool relay1State = !t1Active || btnActive; bool relay1State = !t1Active || btnActive;
bool relay2State = !t2Active; bool relay2State = !t2Active;
@ -406,21 +521,21 @@ void loop() {
leds[0] = ledColor; leds[0] = ledColor;
FastLED.show(); FastLED.show();
if (connected && pCmdChar != nullptr) { if (connected && pCmdChar != nullptr && ecdhReady) {
static uint32_t lastCmd = 0; static uint32_t lastCmd = 0;
if (millis() - lastCmd > 200) { if (millis() - lastCmd > 200) {
if (relay1State != relay1SendState) { if (relay1State != relay1SendState) {
uint8_t pkt[HMAC_PKT_SIZE]; uint8_t pkt[AES_PKT_SIZE];
buildHmacPacket(relay1State ? 0x01 : 0x00, pkt); buildAesPacket(relay1State ? 0x01 : 0x00, pkt);
pCmdChar->writeValue(pkt, HMAC_PKT_SIZE, false); pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay1SendState = relay1State; relay1SendState = relay1State;
ESP_LOGD(TAG, "TX: relay1 %s (seq=%lu)", relay1State ? "ON" : "OFF", seq); ESP_LOGD(TAG, "TX: relay1 %s (seq=%lu)", relay1State ? "ON" : "OFF", seq);
lastCmd = millis(); lastCmd = millis();
} }
if (relay2State != relay2SendState) { if (relay2State != relay2SendState) {
uint8_t pkt[HMAC_PKT_SIZE]; uint8_t pkt[AES_PKT_SIZE];
buildHmacPacket(relay2State ? 0x02 : 0x03, pkt); buildAesPacket(relay2State ? 0x02 : 0x03, pkt);
pCmdChar->writeValue(pkt, HMAC_PKT_SIZE, false); pCmdChar->writeValue(pkt, AES_PKT_SIZE, false);
relay2SendState = relay2State; relay2SendState = relay2State;
ESP_LOGD(TAG, "TX: relay2 %s (seq=%lu)", relay2State ? "ON" : "OFF", seq); ESP_LOGD(TAG, "TX: relay2 %s (seq=%lu)", relay2State ? "ON" : "OFF", seq);
lastCmd = millis(); lastCmd = millis();
@ -456,13 +571,14 @@ void loop() {
static uint32_t heartbeat = 0; static uint32_t heartbeat = 0;
if (millis() - heartbeat > 60000) { 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", !t1Active ? "YES" : "no",
!t2Active ? "YES" : "no", !t2Active ? "YES" : "no",
btnActive ? "YES" : "no", btnActive ? "YES" : "no",
relay1State ? "ON" : "OFF", relay1State ? "ON" : "OFF",
relay2State ? "ON" : "OFF", relay2State ? "ON" : "OFF",
connected ? "yes" : "no"); connected ? "yes" : "no",
ecdhReady ? "yes" : "no");
heartbeat = millis(); 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 <NimBLEAdvertising.h>
#include <FastLED.h> #include <FastLED.h>
#include <mbedtls/md.h> #include <mbedtls/gcm.h>
#include <mbedtls/sha256.h> #include <mbedtls/sha256.h>
#include <Preferences.h> #include <Preferences.h>
#include <esp_ota_ops.h> #include <esp_ota_ops.h>
@ -11,6 +11,7 @@
#include <nvs_flash.h> #include <nvs_flash.h>
#include <esp_log.h> #include <esp_log.h>
#include "version.h" #include "version.h"
#include "ecdh_helper.h"
static const char *TAG = "server"; static const char *TAG = "server";
@ -31,8 +32,8 @@ CRGB leds[NUM_LEDS];
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9" #define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db" #define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da" #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_CHUNK_SIZE 500
#define OTA_CMD_START 0x10 #define OTA_CMD_START 0x10
#define OTA_CMD_END 0x11 #define OTA_CMD_END 0x11
@ -40,14 +41,14 @@ CRGB leds[NUM_LEDS];
#define OTA_STATUS_NAK 0x01 #define OTA_STATUS_NAK 0x01
#define OTA_STATUS_COMPLETE 0x10 #define OTA_STATUS_COMPLETE 0x10
#define OTA_STATUS_ERROR 0x11 #define OTA_STATUS_ERROR 0x11
#define HMAC_KEY_LEN 32
#define ESP_UUID_LEN 16 #define ESP_UUID_LEN 16
#define NVS_NAMESPACE "hmac" #define NVS_NAMESPACE "syl"
#define NVS_KEY_NAME "key" #define NVS_PSK_NAME "psk"
#define NVS_UUID_NAME "uuid" #define NVS_UUID_NAME "uuid"
#define FIRMWARE_VERSION FIRMWARE_VERSION_SERVER #define FIRMWARE_VERSION FIRMWARE_VERSION_SERVER
NimBLECharacteristic *pCharacteristic; NimBLECharacteristic *pCharacteristic;
NimBLECharacteristic *pEcdhChar;
bool relay1State = false; bool relay1State = false;
bool relay2State = false; bool relay2State = false;
@ -58,6 +59,19 @@ uint32_t lastSeq[MAX_CLIENTS] = {0, 0};
uint16_t connHandles[MAX_CLIENTS] = {0xFFFF, 0xFFFF}; uint16_t connHandles[MAX_CLIENTS] = {0xFFFF, 0xFFFF};
int connectedCount = 0; 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 #define STABLE_LED_TIMEOUT_MS 60000
uint32_t connectionStableMs = 0; uint32_t connectionStableMs = 0;
uint32_t relayFlashUntil = 0; uint32_t relayFlashUntil = 0;
@ -77,12 +91,6 @@ bool otaEarlyConfirm = false;
uint32_t otaEndedAt = 0; uint32_t otaEndedAt = 0;
#define OTA_CONFIRM_TIMEOUT_MS 10000 #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) { static int findClientIdx(uint16_t connHandle) {
for (int i = 0; i < MAX_CLIENTS; i++) { for (int i = 0; i < MAX_CLIENTS; i++) {
if (connActive[i] && connHandles[i] == connHandle) return i; if (connActive[i] && connHandles[i] == connHandle) return i;
@ -106,6 +114,9 @@ class MyServerCallbacks : public NimBLEServerCallbacks {
bleRelay1[slot] = false; bleRelay1[slot] = false;
bleRelay2[slot] = false; bleRelay2[slot] = false;
lastSeq[slot] = 0; lastSeq[slot] = 0;
clients[slot].ecdhReady = false;
memset(clients[slot].aesKey, 0, AES_KEY_LEN);
clients[slot].ecdh.free();
connectedCount++; connectedCount++;
ESP_LOGI(TAG, "BLE: connected (slot=%d, clients=%d/%d, mac=%s)", ESP_LOGI(TAG, "BLE: connected (slot=%d, clients=%d/%d, mac=%s)",
slot, connectedCount, MAX_CLIENTS, slot, connectedCount, MAX_CLIENTS,
@ -124,6 +135,9 @@ class MyServerCallbacks : public NimBLEServerCallbacks {
bleRelay1[i] = false; bleRelay1[i] = false;
bleRelay2[i] = false; bleRelay2[i] = false;
lastSeq[i] = 0; lastSeq[i] = 0;
clients[i].ecdhReady = false;
memset(clients[i].aesKey, 0, AES_KEY_LEN);
clients[i].ecdh.free();
connHandles[i] = 0xFFFF; connHandles[i] = 0xFFFF;
connectedCount--; connectedCount--;
ESP_LOGI(TAG, "BLE: disconnected (slot=%d, clients=%d/%d, mac=%s)", 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) { static bool decryptAesGcm(const uint8_t *packet, int idx, uint8_t *outCmd) {
if (idx < 0 || idx >= MAX_CLIENTS || !connActive[idx]) { if (idx < 0 || idx >= MAX_CLIENTS || !connActive[idx]) return false;
ESP_LOGW(TAG, "HMAC verify for invalid idx=%d", idx); if (!clients[idx].ecdhReady) {
ESP_LOGW(TAG, "AES: ECDH not done for slot=%d", idx);
return false; return false;
} }
uint8_t cmd = packet[0]; uint32_t seq = ((uint32_t)packet[0] << 24) |
uint32_t seq = ((uint32_t)packet[1] << 24) | ((uint32_t)packet[1] << 16) |
((uint32_t)packet[2] << 16) | ((uint32_t)packet[2] << 8) |
((uint32_t)packet[3] << 8) | (uint32_t)packet[3];
(uint32_t)packet[4];
if (seq <= lastSeq[idx]) { if (seq <= lastSeq[idx]) {
ESP_LOGW(TAG, "Replay rejected (seq=%lu, last=%lu, slot=%d)", seq, lastSeq[idx], idx); ESP_LOGW(TAG, "Replay rejected (seq=%lu, last=%lu, slot=%d)", seq, lastSeq[idx], idx);
return false; return false;
} }
uint8_t hmacData[5]; uint8_t nonce[12];
hmacData[0] = cmd; memcpy(nonce, packet, 4);
hmacData[1] = (seq >> 24) & 0xFF; memcpy(nonce + 4, packet + 4, 8);
hmacData[2] = (seq >> 16) & 0xFF;
hmacData[3] = (seq >> 8) & 0xFF;
hmacData[4] = seq & 0xFF;
uint8_t computed[32]; mbedtls_gcm_context gcm;
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); mbedtls_gcm_init(&gcm);
mbedtls_md_hmac(mdInfo, hmacKey, HMAC_KEY_LEN, hmacData, 5, computed); 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) { ret = mbedtls_gcm_auth_decrypt(&gcm,
ESP_LOGE(TAG, "HMAC mismatch"); 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; return false;
} }
@ -172,7 +197,7 @@ static bool verifyHmac(const uint8_t *packet, int idx) {
return true; return true;
} }
static bool isHexKey(const String &hex) { static bool isHex(const String &hex) {
for (int i = 0; i < hex.length(); i++) { for (int i = 0; i < hex.length(); i++) {
char c = hex.charAt(i); char c = hex.charAt(i);
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false; if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) return false;
@ -180,8 +205,8 @@ static bool isHexKey(const String &hex) {
return true; return true;
} }
static void parseHexKey(const String &hex, uint8_t *out) { static void parseHex(const String &hex, uint8_t *out, int len) {
for (int i = 0; i < HMAC_KEY_LEN; i++) { for (int i = 0; i < len; i++) {
out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16); 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 { class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override { void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
if (otaInProgress) return; if (otaInProgress) return;
@ -337,19 +419,19 @@ class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks {
} }
std::string value = pChar->getValue(); 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", 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; return;
} }
const uint8_t *pkt = (const uint8_t *)value.data(); const uint8_t *pkt = (const uint8_t *)value.data();
if (!verifyHmac(pkt, idx)) { uint8_t cmd;
if (!decryptAesGcm(pkt, idx, &cmd)) {
return; return;
} }
uint8_t cmd = pkt[0];
uint32_t seq = lastSeq[idx]; uint32_t seq = lastSeq[idx];
if (cmd == 0x01) { if (cmd == 0x01) {
@ -376,16 +458,16 @@ void setup() {
FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
preferences.begin(NVS_NAMESPACE, true); 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 uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end(); preferences.end();
if (len == HMAC_KEY_LEN) { if (pskLen == PSK_LEN) {
keyProvisioned = true; pskProvisioned = true;
ESP_LOGI(TAG, "HMAC key loaded from NVS"); ESP_LOGI(TAG, "PSK loaded from NVS");
} else { } else {
keyProvisioned = false; pskProvisioned = false;
ESP_LOGW(TAG, "No HMAC key — connect USB and run SET_KEY"); ESP_LOGW(TAG, "No PSK — connect USB and run SET_KEY");
} }
if (uuidLen == ESP_UUID_LEN) { if (uuidLen == ESP_UUID_LEN) {
@ -401,6 +483,11 @@ void setup() {
ESP_LOGD(TAG, "Generated new UUID"); 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(TOUCH1_PIN, INPUT);
pinMode(TOUCH2_PIN, INPUT); pinMode(TOUCH2_PIN, INPUT);
pinMode(BUTTON_PIN, INPUT); pinMode(BUTTON_PIN, INPUT);
@ -424,6 +511,12 @@ void setup() {
); );
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks()); pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
pEcdhChar = pService->createCharacteristic(
ECDH_UUID,
NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY
);
pEcdhChar->setCallbacks(new EcdhCharacteristicCallbacks());
NimBLECharacteristic *pUuidChar = pService->createCharacteristic( NimBLECharacteristic *pUuidChar = pService->createCharacteristic(
UUID_CHAR_UUID, UUID_CHAR_UUID,
NIMBLE_PROPERTY::READ NIMBLE_PROPERTY::READ
@ -442,6 +535,8 @@ void setup() {
); );
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks()); pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
pService->start();
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName("SylDa_Server"); pAdvertising->setName("SylDa_Server");
pAdvertising->addServiceUUID(SERVICE_UUID); pAdvertising->addServiceUUID(SERVICE_UUID);
@ -456,10 +551,10 @@ void setup() {
bool advOk = pAdvertising->start(); bool advOk = pAdvertising->start();
ESP_LOGI(TAG, "BLE: advertising started"); ESP_LOGI(TAG, "BLE: advertising started");
if (keyProvisioned) { if (pskProvisioned) {
ESP_LOGD(TAG, "HMAC key present"); ESP_LOGD(TAG, "PSK present");
} else { } 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."); ESP_LOGI(TAG, "Setup done.");
@ -473,13 +568,13 @@ void loop() {
if (c == '\n') { if (c == '\n') {
serialBuf.trim(); serialBuf.trim();
if (serialBuf == "KEY?") { if (serialBuf == "KEY?") {
if (keyProvisioned && uuidProvisioned) { if (pskProvisioned && uuidProvisioned) {
Serial.print("DEVICE_ID:"); Serial.print("DEVICE_ID:");
for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]); for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]);
Serial.println(); Serial.println();
Serial.print("HMAC_KEY:"); Serial.print("PSK:");
for (int i = 0; i < HMAC_KEY_LEN; i++) { for (int i = 0; i < PSK_LEN; i++) {
Serial.printf("%02X", hmacKey[i]); Serial.printf("%02X", psk[i]);
} }
Serial.println(); Serial.println();
} else { } else {
@ -493,24 +588,22 @@ void loop() {
} else if (serialBuf.startsWith("SET_KEY:")) { } else if (serialBuf.startsWith("SET_KEY:")) {
String hex = serialBuf.substring(8); String hex = serialBuf.substring(8);
hex.toUpperCase(); hex.toUpperCase();
if (hex.length() == 64 && isHexKey(hex)) { if (hex.length() == 64 && isHex(hex)) {
parseHexKey(hex, hmacKey); parseHex(hex, psk, PSK_LEN);
preferences.begin(NVS_NAMESPACE, false); preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN); preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN);
preferences.end(); preferences.end();
keyProvisioned = true; pskProvisioned = true;
Serial.println("OK"); Serial.println("OK");
Serial.println("HMAC key stored in NVS"); Serial.println("PSK stored in NVS");
} else { } else {
Serial.println("ERROR: invalid key format"); Serial.println("ERROR: invalid key format");
} }
} else if (serialBuf.startsWith("SET_ID:")) { } else if (serialBuf.startsWith("SET_ID:")) {
String hex = serialBuf.substring(7); String hex = serialBuf.substring(7);
hex.toUpperCase(); hex.toUpperCase();
if (hex.length() == 32 && isHexKey(hex)) { if (hex.length() == 32 && isHex(hex)) {
for (int i = 0; i < ESP_UUID_LEN; i++) { parseHex(hex, deviceUuid, ESP_UUID_LEN);
deviceUuid[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16);
}
preferences.begin(NVS_NAMESPACE, false); preferences.begin(NVS_NAMESPACE, false);
preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN); preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
preferences.end(); preferences.end();

View file

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

View file

@ -1,2 +1,2 @@
server=17 server=18
client=3 client=4