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 0000000..6c98bc7 Binary files /dev/null and b/icon.png differ 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