From d6f1062a44e1fdd38e6e209062bbaf56f430ec3a Mon Sep 17 00:00:00 2001 From: eddy Date: Mon, 3 Aug 2026 04:33:52 +0200 Subject: [PATCH] Replace HMAC-SHA256 with AES-256-GCM + ECDH key exchange for BLE relay commands (issue #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../nl/eddydevink/sylda/BleRelayManager.kt | 295 +++++++++++++++--- .../java/nl/eddydevink/sylda/PrefsHelper.kt | 2 +- .../nl/eddydevink/sylda/SettingsActivity.kt | 18 +- docs/plan-issue-2-aes-ecdh.md | 63 ++++ icon.png | Bin 0 -> 81244 bytes icon.svg | 34 ++ src/client.cpp | 226 ++++++++++---- src/ecdh_helper.h | 114 +++++++ src/main.cpp | 211 +++++++++---- src/version.h | 4 +- version.txt | 4 +- 11 files changed, 793 insertions(+), 178 deletions(-) create mode 100644 docs/plan-issue-2-aes-ecdh.md create mode 100644 icon.png create mode 100644 icon.svg create mode 100644 src/ecdh_helper.h diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt index cf5a98e..4b37934 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -21,8 +21,15 @@ import android.util.Log import androidx.core.app.ActivityCompat import java.nio.ByteBuffer 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 javax.crypto.Cipher +import javax.crypto.KeyAgreement import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec 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 VERSION_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26db") 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 const val SCAN_TIMEOUT_MS = 30000L private const val RECONNECT_DELAY_MS = 2000L private const val MAX_SCAN_RETRIES = 3 - private const val HMAC_PKT_SIZE = 37 - private const val HMAC_LEN = 32 + private const val AES_PKT_SIZE = 29 + 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 ECDH_TIMEOUT_MS = 5000L } private var bluetoothGatt: BluetoothGatt? = null private var relayCharacteristic: BluetoothGattCharacteristic? = null + private var ecdhCharacteristic: BluetoothGattCharacteristic? = null private var isScanning = false private var scanTimedOut = false private var scanTimeoutRetries = 0 private val handler = Handler(Looper.getMainLooper()) 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 triedAddresses = mutableSetOf() private var otaCharacteristic: BluetoothGattCharacteristic? = null private var espVersion: String? = null private var appVersion: String = "" private var negotiatedMtu: Int = 23 + private var ecdhDone = false + @Volatile private var ecdhDeferred: CompletableDeferred? = null + private var ecdhNotifyBuf: ByteArray = ByteArray(0) + + private class CompletableDeferred { + 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 { fun onOtaNotify(status: Int, seq: Int) @@ -107,8 +134,8 @@ class BleRelayManager( return } loadState() - if (hmacKey == null || deviceUuid == null) { - handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } + if (psk == null || deviceUuid == null) { + handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } return } isScanning = true @@ -175,7 +202,7 @@ class BleRelayManager( fun sendRelayCommand(cmd: Byte) { val char = relayCharacteristic ?: return val gatt = bluetoothGatt ?: return - val key = hmacKey ?: return + val key = aesKey ?: return val packet = buildPacket(cmd, key) char.value = packet @@ -256,9 +283,9 @@ class BleRelayManager( @SuppressLint("MissingPermission") override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { - if (hmacKey == null) { - Log.w(TAG, "GATT connected but no key -- disconnecting") - handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } + if (psk == null) { + Log.w(TAG, "GATT connected but no PSK -- disconnecting") + handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } gatt.disconnect() return } @@ -271,6 +298,9 @@ class BleRelayManager( gatt.close() bluetoothGatt = null relayCharacteristic = null + ecdhCharacteristic = null + aesKey = null + ecdhDone = false seq = 1 triedAddresses.clear() handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS) @@ -296,6 +326,7 @@ class BleRelayManager( if (service != null) { relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) otaCharacteristic = service.getCharacteristic(OTA_UUID) + ecdhCharacteristic = service.getCharacteristic(ECDH_UUID) val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) val versionChar = service.getCharacteristic(VERSION_UUID) if (relayCharacteristic != null && uuidChar != null) { @@ -316,21 +347,8 @@ class BleRelayManager( if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) { val storedUuid = deviceUuid if (storedUuid != null && value.contentEquals(storedUuid)) { - Log.d(TAG, "UUID verified, 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, 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) } - } + Log.d(TAG, "UUID verified, starting ECDH handshake...") + startEcdhHandshake(gatt) } else { Log.w(TAG, "UUID mismatch -- disconnecting") triedAddresses.add(gatt.device.address) @@ -366,35 +384,185 @@ class BleRelayManager( value: ByteArray ) { if (characteristic.uuid == OTA_UUID && value.size >= 1) { - val status = value[0].toInt() and 0xFF - val seq = if (value.size >= 5) { + val statusVal = value[0].toInt() and 0xFF + val seqVal = if (value.size >= 5) { ((value[1].toInt() and 0xFF) shl 24) or ((value[2].toInt() and 0xFF) shl 16) or ((value[3].toInt() and 0xFF) shl 8) or (value[4].toInt() and 0xFF) } else 0 - val statusName = when (status) { 0x00 -> "ACK"; 0x01 -> "NAK"; 0x10 -> "COMPLETE"; 0x11 -> "ERROR"; else -> "UNKNOWN(0x${status.toString(16)})" } - Log.d(TAG, "OTA notify: status=$statusName seq=$seq raw=${value.joinToString("") { "%02X".format(it) }}") - handler.post { otaNotifyCallback?.onOtaNotify(status, seq) } + 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=$seqVal raw=${value.joinToString("") { "%02X".format(it) }}") + 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() { - val prefs = PrefsHelper.getPrefs(context) - val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) - hmacKey = 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, "HMAC key loaded: ${hmacKey?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq") + @SuppressLint("MissingPermission") + private fun startEcdhHandshake(gatt: BluetoothGatt) { + val char = ecdhCharacteristic + if (char == null) { + Log.w(TAG, "ECDH characteristic not found") + handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) } + gatt.disconnect() + return + } + + 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? { - 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 encodeEcPubKey(pubKey: ECPublicKey): ByteArray { + val x = pubKey.w.affineX.toByteArray() + val y = pubKey.w.affineY.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 { @@ -403,16 +571,43 @@ class BleRelayManager( 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 { val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array() - val hmacData = ByteArray(5) - hmacData[0] = cmd - System.arraycopy(seqBytes, 0, hmacData, 1, 4) - val hmac = hmacSha256(hmacData, key) - val packet = ByteArray(HMAC_PKT_SIZE) - packet[0] = cmd - System.arraycopy(seqBytes, 0, packet, 1, 4) - System.arraycopy(hmac, 0, packet, 5, HMAC_LEN) + val nonceRandom = ByteArray(8) + SecureRandom().nextBytes(nonceRandom) + + val nonce = ByteArray(GCM_NONCE_LEN) + System.arraycopy(seqBytes, 0, nonce, 0, 4) + System.arraycopy(nonceRandom, 0, nonce, 4, 8) + + 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 } } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt b/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt index 9ea18ca..fdc6c45 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt @@ -9,7 +9,7 @@ object PrefsHelper { private const val TAG = "PrefsHelper" 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_SEQ = "seq_counter" const val PREF_THEME = "theme" diff --git a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt index 58496f6..cf9ea68 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt @@ -56,10 +56,10 @@ class SettingsActivity : AppCompatActivity() { private const val STYLE_SPINNER = 0 private const val STYLE_HORIZONTAL = 1 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_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 REBOOT_WAIT_MS = 5000L private const val REBOOT_POLL_MS = 500L @@ -642,16 +642,16 @@ class SettingsActivity : AppCompatActivity() { Thread.sleep(RESPONSE_WAIT_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 noKey = response.lines().any { it.trim() == "NO_KEY" } 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() - 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" }) { val espKeyUpper = hexKey.uppercase() val espUuidUpper = hexUuid.uppercase() @@ -666,7 +666,7 @@ class SettingsActivity : AppCompatActivity() { val newKey: String val newUuid: String 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 newUuid = if (PrefsHelper.getDeviceMode(this) == "server") { generateRandomHex(UUID_BYTE_COUNT) @@ -757,7 +757,7 @@ class SettingsActivity : AppCompatActivity() { val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS) if (len > 0) { 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 } } @@ -802,7 +802,7 @@ class SettingsActivity : AppCompatActivity() { private fun generateRandomHexKey(): String { val random = SecureRandom() - val bytes = ByteArray(HMAC_BYTE_COUNT) + val bytes = ByteArray(PSK_BYTE_COUNT) random.nextBytes(bytes) return bytes.joinToString("") { "%02X".format(it) } } diff --git a/docs/plan-issue-2-aes-ecdh.md b/docs/plan-issue-2-aes-ecdh.md new file mode 100644 index 0000000..96bc8b7 --- /dev/null +++ b/docs/plan-issue-2-aes-ecdh.md @@ -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. diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6c98bc7914eb6e4f4bbd8a3426aa916d16590887 GIT binary patch literal 81244 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 literal 0 HcmV?d00001 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..0c2f4cd --- /dev/null +++ b/icon.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client.cpp b/src/client.cpp index af513c3..2bc80ce 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -2,12 +2,13 @@ #include #include -#include +#include #include #include #include #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(); } diff --git a/src/ecdh_helper.h b/src/ecdh_helper.h new file mode 100644 index 0000000..3b8c38c --- /dev/null +++ b/src/ecdh_helper.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#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; + } + } +}; diff --git a/src/main.cpp b/src/main.cpp index 473712c..aca6206 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include #include #include @@ -11,6 +11,7 @@ #include #include #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(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(); diff --git a/src/version.h b/src/version.h index 9418701..b402dd7 100644 --- a/src/version.h +++ b/src/version.h @@ -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" diff --git a/version.txt b/version.txt index b579b18..b023292 100644 --- a/version.txt +++ b/version.txt @@ -1,2 +1,2 @@ -server=17 -client=3 +server=18 +client=4