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 d1a6826..0d09c00 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -21,15 +21,8 @@ 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 @@ -45,51 +38,30 @@ 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 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 HMAC_PKT_SIZE = 37 + private const val HMAC_LEN = 32 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 psk: ByteArray? = null - @Volatile private var aesKey: ByteArray? = null + private var hmacKey: 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 var ecdhCccdPending = false - - 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) @@ -148,8 +120,8 @@ class BleRelayManager( return } loadState() - if (psk == null || deviceUuid == null) { - handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } + if (hmacKey == null || deviceUuid == null) { + handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } return } isScanning = true @@ -216,7 +188,7 @@ class BleRelayManager( fun sendRelayCommand(cmd: Byte) { val char = relayCharacteristic ?: return val gatt = bluetoothGatt ?: return - val key = aesKey ?: return + val key = hmacKey ?: return val packet = buildPacket(cmd, key) char.value = packet @@ -297,9 +269,9 @@ class BleRelayManager( @SuppressLint("MissingPermission") override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED) { - if (psk == null) { - Log.w(TAG, "GATT connected but no PSK -- disconnecting") - handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } + if (hmacKey == null) { + Log.w(TAG, "GATT connected but no key -- disconnecting") + handler.post { callbacks.onStatusChanged(BleStatus.NoKey) } gatt.disconnect() return } @@ -312,10 +284,6 @@ class BleRelayManager( gatt.close() bluetoothGatt = null relayCharacteristic = null - ecdhCharacteristic = null - aesKey = null - ecdhDone = false - ecdhCccdPending = false seq = 1 triedAddresses.clear() handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS) @@ -341,7 +309,6 @@ 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) { @@ -362,8 +329,21 @@ 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, starting ECDH handshake...") - startEcdhHandshake(gatt) + 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) } + } } else { Log.w(TAG, "UUID mismatch -- disconnecting") triedAddresses.add(gatt.device.address) @@ -399,227 +379,29 @@ class BleRelayManager( value: ByteArray ) { if (characteristic.uuid == OTA_UUID && value.size >= 1) { - val statusVal = value[0].toInt() and 0xFF - val seqVal = if (value.size >= 5) { + val status = value[0].toInt() and 0xFF + val seq = 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 (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)") + 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) } } } - - @SuppressLint("MissingPermission") - override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { - if (ecdhCccdPending && descriptor.characteristic.uuid == ECDH_UUID) { - ecdhCccdPending = false - if (status == BluetoothGatt.GATT_SUCCESS) { - sendEcdhHandshake(gatt) - } else { - Log.w(TAG, "ECDH: CCCD write failed (status=$status)") - gatt.disconnect() - } - } - } - } - - @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 - } - - val cccd = char.getDescriptor(OTA_CCCD_UUID) - if (cccd == null) { - Log.w(TAG, "ECDH: no CCCD descriptor") - gatt.disconnect() - return - } - - if (!gatt.setCharacteristicNotification(char, true)) { - Log.w(TAG, "ECDH: setCharacteristicNotification failed") - gatt.disconnect() - return - } - - ecdhCccdPending = true - cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - if (!gatt.writeDescriptor(cccd)) { - ecdhCccdPending = false - Log.w(TAG, "ECDH: writeDescriptor for CCCD returned false") - gatt.disconnect() - } - } - - @SuppressLint("MissingPermission") - private fun sendEcdhHandshake(gatt: BluetoothGatt) { - 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() - ecdhCharacteristic!!.value = handshake - ecdhCharacteristic!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - if (!gatt.writeCharacteristic(ecdhCharacteristic!!)) { - 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 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 { - val mac = Mac.getInstance("HmacSHA256") - mac.init(SecretKeySpec(key, "HmacSHA256")) - 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) } + 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, "PSK loaded: ${psk?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq") + Log.d(TAG, "HMAC key loaded: ${hmacKey?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq") } private fun hexStringToBytes(hex: String): ByteArray? { @@ -628,26 +410,22 @@ class BleRelayManager( return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } + private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } + private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray { val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array() - 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) + 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) 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 fdc6c45..9ea18ca 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 = "psk" + const val PREF_KEY = "hmac_key" 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 91ccaec..24990e7 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 PSK_HEX_LEN = 64 + private const val HMAC_HEX_LEN = 64 private const val UUID_HEX_LEN = 32 private const val UUID_BYTE_COUNT = 16 - private const val PSK_BYTE_COUNT = 32 + private const val HMAC_BYTE_COUNT = 32 private const val VERSION_TIMEOUT_MS = 3000L private const val REBOOT_WAIT_MS = 5000L private const val REBOOT_POLL_MS = 500L @@ -660,16 +660,16 @@ class SettingsActivity : AppCompatActivity() { Thread.sleep(RESPONSE_WAIT_MS) val response = readResponse(connection, epIn, KEY_TIMEOUT_MS) - Log.d(TAG, "Attempt $attempt: got ${response.lines().size} line(s)") + Log.d(TAG, "Attempt $attempt response: ${response.take(300)}") - val keyLine = response.lines().firstOrNull { it.startsWith("PSK:") } + val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") } 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("PSK:").trim() + val hexKey = keyLine.removePrefix("HMAC_KEY:").trim() val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim() - if (hexKey.length == PSK_HEX_LEN && hexKey.all { it in "0123456789ABCDEFabcdef" } && + if (hexKey.length == HMAC_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() @@ -684,7 +684,7 @@ class SettingsActivity : AppCompatActivity() { val newKey: String val newUuid: String if (appKey != null) { - Log.d(TAG, "ESP has no keys, using existing PSK") + Log.d(TAG, "ESP has no keys, using existing HMAC key") newKey = appKey newUuid = if (PrefsHelper.getDeviceMode(this) == "server") { generateRandomHex(UUID_BYTE_COUNT) @@ -778,7 +778,7 @@ class SettingsActivity : AppCompatActivity() { reads++ sb.append(String(buf, 0, len, StandardCharsets.US_ASCII)) Log.d(TAG, "readResponse: chunk len=$len, sb now='${sb.toString().replace("\r","\\r").replace("\n","\\n")}'") - if ((sb.contains("PSK:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) { + if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) { break } } @@ -824,7 +824,7 @@ class SettingsActivity : AppCompatActivity() { private fun generateRandomHexKey(): String { val random = SecureRandom() - val bytes = ByteArray(PSK_BYTE_COUNT) + val bytes = ByteArray(HMAC_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 deleted file mode 100644 index 96bc8b7..0000000 --- a/docs/plan-issue-2-aes-ecdh.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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 deleted file mode 100644 index 6c98bc7..0000000 Binary files a/icon.png and /dev/null differ diff --git a/icon.svg b/icon.svg deleted file mode 100644 index 0c2f4cd..0000000 --- a/icon.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/client.cpp b/src/client.cpp index 1b9ae2a..143ac33 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -2,13 +2,12 @@ #include #include -#include +#include #include #include #include #include "version.h" -#include "ecdh_helper.h" static const char *TAG = "client"; @@ -23,20 +22,21 @@ 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 ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc" +#define HMAC_PKT_SIZE 37 +#define HMAC_KEY_LEN 32 #define ESP_UUID_LEN 16 -#define NVS_NAMESPACE "syl" -#define NVS_PSK_NAME "psk" +#define NVS_NAMESPACE "hmac" +#define NVS_KEY_NAME "key" #define NVS_UUID_NAME "uuid" #define NVS_SERVER_NAME "server" #define DEBOUNCE_MS 50 #define SCAN_INTERVAL 6000 #define SCAN_TIMEOUT 5000 -uint8_t psk[PSK_LEN]; +uint8_t hmacKey[HMAC_KEY_LEN]; uint8_t deviceUuid[ESP_UUID_LEN]; uint8_t serverUuid[ESP_UUID_LEN]; -bool pskProvisioned = false; +bool keyProvisioned = false; bool uuidProvisioned = false; bool serverProvisioned = false; bool allProvisioned = false; @@ -50,9 +50,6 @@ 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,23 +57,8 @@ bool otaInProgress = false; bool otaEnded = false; 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))) { - serverAddress = advertisedDevice->getAddress(); - serverFound = true; - NimBLEDevice::getScan()->stop(); - } - } -}; - class MyClientCallbacks : public NimBLEClientCallbacks { void onConnect(NimBLEClient *client) override { connected = true; @@ -87,15 +69,13 @@ 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 isHex(const String &hex) { +static bool isHexKey(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; @@ -103,41 +83,24 @@ static bool isHex(const String &hex) { return true; } -static void parseHex(const String &hex, uint8_t *out, int len) { +static void parseHexKey(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 buildAesPacket(uint8_t cmd, uint8_t *packet) { +static void buildHmacPacket(uint8_t cmd, uint8_t *packet) { uint32_t s = ++seq; - 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); + 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); } static void disconnectClient() { @@ -151,89 +114,6 @@ 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() { @@ -277,19 +157,10 @@ 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, ECDH done, ready"); + ESP_LOGI(TAG, "BLE: server UUID verified, ready"); return true; } @@ -303,17 +174,17 @@ void setup() { FastLED.show(); preferences.begin(NVS_NAMESPACE, true); - size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN); + size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_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 (pskLen == PSK_LEN) { - pskProvisioned = true; - ESP_LOGI(TAG, "PSK loaded from NVS"); + if (len == HMAC_KEY_LEN) { + keyProvisioned = true; + ESP_LOGI(TAG, "HMAC key loaded from NVS"); } else { - pskProvisioned = false; - ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision"); + keyProvisioned = false; + ESP_LOGW(TAG, "No HMAC key — use USB SET_KEY to provision"); } if (uuidLen == ESP_UUID_LEN) { @@ -337,7 +208,7 @@ void setup() { ESP_LOGW(TAG, "No server UUID — use USB SET_SERVER to provision"); } - allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned; + allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned; pinMode(TOUCH1_PIN, INPUT_PULLUP); pinMode(TOUCH2_PIN, INPUT_PULLUP); @@ -373,12 +244,12 @@ void loop() { if (c == '\n') { serialBuf.trim(); if (serialBuf == "KEY?") { - if (pskProvisioned && uuidProvisioned) { + if (keyProvisioned && uuidProvisioned) { Serial.print("DEVICE_ID:"); for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]); Serial.println(); - Serial.print("PSK:"); - for (int i = 0; i < PSK_LEN; i++) Serial.printf("%02X", psk[i]); + Serial.print("HMAC_KEY:"); + for (int i = 0; i < HMAC_KEY_LEN; i++) Serial.printf("%02X", hmacKey[i]); Serial.println(); Serial.print("SERVER_UUID:"); if (serverProvisioned) { @@ -396,15 +267,15 @@ void loop() { } else if (serialBuf.startsWith("SET_KEY:")) { String hex = serialBuf.substring(8); hex.toUpperCase(); - if (hex.length() == 64 && isHex(hex)) { - parseHex(hex, psk, PSK_LEN); + if (hex.length() == 64 && isHexKey(hex)) { + parseHexKey(hex, hmacKey, HMAC_KEY_LEN); preferences.begin(NVS_NAMESPACE, false); - preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN); + preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN); preferences.end(); - pskProvisioned = true; - allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned; + keyProvisioned = true; + allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned; Serial.println("OK"); - Serial.println("PSK stored in NVS"); + Serial.println("HMAC key stored in NVS"); if (allProvisioned && !bleReady) { NimBLEDevice::init("SylDa_Client"); bleReady = true; @@ -417,13 +288,13 @@ void loop() { } else if (serialBuf.startsWith("SET_ID:")) { String hex = serialBuf.substring(7); hex.toUpperCase(); - if (hex.length() == 32 && isHex(hex)) { - parseHex(hex, deviceUuid, ESP_UUID_LEN); + if (hex.length() == 32 && isHexKey(hex)) { + parseHexKey(hex, deviceUuid, ESP_UUID_LEN); preferences.begin(NVS_NAMESPACE, false); preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN); preferences.end(); uuidProvisioned = true; - allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned; + allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned; Serial.println("OK"); Serial.println("UUID stored in NVS"); } else { @@ -433,13 +304,13 @@ void loop() { } else if (serialBuf.startsWith("SET_SERVER:")) { String hex = serialBuf.substring(11); hex.toUpperCase(); - if (hex.length() == 32 && isHex(hex)) { - parseHex(hex, serverUuid, ESP_UUID_LEN); + if (hex.length() == 32 && isHexKey(hex)) { + parseHexKey(hex, serverUuid, ESP_UUID_LEN); preferences.begin(NVS_NAMESPACE, false); preferences.putBytes(NVS_SERVER_NAME, serverUuid, ESP_UUID_LEN); preferences.end(); serverProvisioned = true; - allProvisioned = pskProvisioned && uuidProvisioned && serverProvisioned; + allProvisioned = keyProvisioned && uuidProvisioned && serverProvisioned; Serial.println("OK"); Serial.println("Server UUID stored in NVS"); if (allProvisioned && !bleReady) { @@ -477,6 +348,9 @@ 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; @@ -516,21 +390,21 @@ void loop() { leds[0] = ledColor; FastLED.show(); - if (connected && pCmdChar != nullptr && ecdhReady) { + if (connected && pCmdChar != nullptr) { static uint32_t lastCmd = 0; if (millis() - lastCmd > 200) { if (relay1State != relay1SendState) { - uint8_t pkt[AES_PKT_SIZE]; - buildAesPacket(relay1State ? 0x01 : 0x00, pkt); - pCmdChar->writeValue(pkt, AES_PKT_SIZE, false); + uint8_t pkt[HMAC_PKT_SIZE]; + buildHmacPacket(relay1State ? 0x01 : 0x00, pkt); + pCmdChar->writeValue(pkt, HMAC_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[AES_PKT_SIZE]; - buildAesPacket(relay2State ? 0x02 : 0x03, pkt); - pCmdChar->writeValue(pkt, AES_PKT_SIZE, false); + uint8_t pkt[HMAC_PKT_SIZE]; + buildHmacPacket(relay2State ? 0x02 : 0x03, pkt); + pCmdChar->writeValue(pkt, HMAC_PKT_SIZE, false); relay2SendState = relay2State; ESP_LOGD(TAG, "TX: relay2 %s (seq=%lu)", relay2State ? "ON" : "OFF", seq); lastCmd = millis(); @@ -574,14 +448,13 @@ 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 ecdh=%s", + ESP_LOGD(TAG, "[heartbeat] t1=%s t2=%s btn=%s r1=%s r2=%s connected=%s", !t1Active ? "YES" : "no", !t2Active ? "YES" : "no", btnActive ? "YES" : "no", relay1State ? "ON" : "OFF", relay2State ? "ON" : "OFF", - connected ? "yes" : "no", - ecdhReady ? "yes" : "no"); + connected ? "yes" : "no"); heartbeat = millis(); } diff --git a/src/ecdh_helper.h b/src/ecdh_helper.h deleted file mode 100644 index 3b8c38c..0000000 --- a/src/ecdh_helper.h +++ /dev/null @@ -1,114 +0,0 @@ -#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 17f7848..5b3ab7f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include #include #include @@ -11,7 +11,6 @@ #include #include #include "version.h" -#include "ecdh_helper.h" static const char *TAG = "server"; @@ -32,8 +31,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 @@ -41,15 +40,15 @@ CRGB leds[NUM_LEDS]; #define OTA_STATUS_NAK 0x01 #define OTA_STATUS_COMPLETE 0x10 #define OTA_STATUS_ERROR 0x11 +#define HMAC_KEY_LEN 32 #define ESP_UUID_LEN 16 -#define NVS_NAMESPACE "syl" -#define NVS_PSK_NAME "psk" +#define NVS_NAMESPACE "hmac" +#define NVS_KEY_NAME "key" #define NVS_UUID_NAME "uuid" #define FIRMWARE_VERSION FIRMWARE_VERSION_SERVER NimBLECharacteristic *pCharacteristic; NimBLECharacteristic *pUuidChar; -NimBLECharacteristic *pEcdhChar; bool relay1State = false; bool relay2State = false; @@ -60,19 +59,6 @@ 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; @@ -92,6 +78,12 @@ 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; @@ -115,9 +107,6 @@ 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, @@ -136,9 +125,6 @@ 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)", @@ -150,47 +136,36 @@ class MyServerCallbacks : public NimBLEServerCallbacks { } }; -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); +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); return false; } - uint32_t seq = ((uint32_t)packet[0] << 24) | - ((uint32_t)packet[1] << 16) | - ((uint32_t)packet[2] << 8) | - (uint32_t)packet[3]; + 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]; if (seq <= lastSeq[idx]) { ESP_LOGW(TAG, "Replay rejected (seq=%lu, last=%lu, slot=%d)", seq, lastSeq[idx], idx); return false; } - uint8_t nonce[12]; - memcpy(nonce, packet, 4); - memcpy(nonce + 4, packet + 4, 8); + 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; - 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; - } + 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); - 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); + if (memcmp(computed, packet + 5, 32) != 0) { + ESP_LOGE(TAG, "HMAC mismatch"); return false; } @@ -198,7 +173,7 @@ static bool decryptAesGcm(const uint8_t *packet, int idx, uint8_t *outCmd) { return true; } -static bool isHex(const String &hex) { +static bool isHexKey(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; @@ -206,8 +181,8 @@ static bool isHex(const String &hex) { return true; } -static void parseHex(const String &hex, uint8_t *out, int len) { - for (int i = 0; i < len; i++) { +static void parseHexKey(const String &hex, uint8_t *out) { + for (int i = 0; i < HMAC_KEY_LEN; i++) { out[i] = (uint8_t)strtoul(hex.substring(i * 2, i * 2 + 2).c_str(), nullptr, 16); } } @@ -352,63 +327,6 @@ 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; @@ -420,19 +338,19 @@ class MyCharacteristicCallbacks : public NimBLECharacteristicCallbacks { } std::string value = pChar->getValue(); - if (value.length() != AES_PKT_SIZE) { + if (value.length() != HMAC_PKT_SIZE) { ESP_LOGW(TAG, "BLE: bad packet length %d (expected %d), slot=%d", - (int)value.length(), AES_PKT_SIZE, idx); + (int)value.length(), HMAC_PKT_SIZE, idx); return; } const uint8_t *pkt = (const uint8_t *)value.data(); - uint8_t cmd; - if (!decryptAesGcm(pkt, idx, &cmd)) { + if (!verifyHmac(pkt, idx)) { return; } + uint8_t cmd = pkt[0]; uint32_t seq = lastSeq[idx]; if (cmd == 0x01) { @@ -459,16 +377,16 @@ void setup() { FastLED.addLeds(leds, NUM_LEDS); preferences.begin(NVS_NAMESPACE, true); - size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN); + size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN); size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN); preferences.end(); - if (pskLen == PSK_LEN) { - pskProvisioned = true; - ESP_LOGI(TAG, "PSK loaded from NVS"); + if (len == HMAC_KEY_LEN) { + keyProvisioned = true; + ESP_LOGI(TAG, "HMAC key loaded from NVS"); } else { - pskProvisioned = false; - ESP_LOGW(TAG, "No PSK — connect USB and run SET_KEY"); + keyProvisioned = false; + ESP_LOGW(TAG, "No HMAC key — connect USB and run SET_KEY"); } if (uuidLen == ESP_UUID_LEN) { @@ -484,13 +402,8 @@ 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(TOUCH1_PIN, INPUT_PULLUP); + pinMode(TOUCH2_PIN, INPUT_PULLUP); pinMode(BUTTON_PIN, INPUT); pinMode(RELAY1_PIN, OUTPUT); pinMode(RELAY2_PIN, OUTPUT); @@ -512,12 +425,6 @@ void setup() { ); pCharacteristic->setCallbacks(new MyCharacteristicCallbacks()); - pEcdhChar = pService->createCharacteristic( - ECDH_UUID, - NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY - ); - pEcdhChar->setCallbacks(new EcdhCharacteristicCallbacks()); - pUuidChar = pService->createCharacteristic( UUID_CHAR_UUID, NIMBLE_PROPERTY::READ @@ -536,8 +443,6 @@ void setup() { ); pOtaChar->setCallbacks(new OtaCharacteristicCallbacks()); - pService->start(); - NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); pAdvertising->setName("SylDa_Server"); @@ -558,10 +463,10 @@ void setup() { bool advOk = pAdvertising->start(); ESP_LOGI(TAG, "BLE: advertising started"); - if (pskProvisioned) { - ESP_LOGD(TAG, "PSK present"); + if (keyProvisioned) { + ESP_LOGD(TAG, "HMAC key present"); } else { - ESP_LOGW(TAG, "No PSK — use USB SET_KEY to provision"); + ESP_LOGW(TAG, "No HMAC key — use USB SET_KEY to provision"); } ESP_LOGI(TAG, "Setup done."); @@ -575,13 +480,13 @@ void loop() { if (c == '\n') { serialBuf.trim(); if (serialBuf == "KEY?") { - if (pskProvisioned && uuidProvisioned) { + if (keyProvisioned && uuidProvisioned) { Serial.print("DEVICE_ID:"); for (int i = 0; i < ESP_UUID_LEN; i++) Serial.printf("%02X", deviceUuid[i]); Serial.println(); - Serial.print("PSK:"); - for (int i = 0; i < PSK_LEN; i++) { - Serial.printf("%02X", psk[i]); + Serial.print("HMAC_KEY:"); + for (int i = 0; i < HMAC_KEY_LEN; i++) { + Serial.printf("%02X", hmacKey[i]); } Serial.println(); } else { @@ -596,36 +501,29 @@ void loop() { } else if (serialBuf.startsWith("SET_KEY:")) { String hex = serialBuf.substring(8); hex.toUpperCase(); - if (hex.length() == 64 && isHex(hex)) { - parseHex(hex, psk, PSK_LEN); + if (hex.length() == 64 && isHexKey(hex)) { + parseHexKey(hex, hmacKey); preferences.begin(NVS_NAMESPACE, false); - preferences.putBytes(NVS_PSK_NAME, psk, PSK_LEN); + preferences.putBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN); preferences.end(); - pskProvisioned = true; + keyProvisioned = true; Serial.println("OK"); - Serial.println("PSK stored in NVS"); + Serial.println("HMAC key 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 && isHex(hex)) { - parseHex(hex, deviceUuid, ESP_UUID_LEN); + 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); + } preferences.begin(NVS_NAMESPACE, false); preferences.putBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN); preferences.end(); uuidProvisioned = true; pUuidChar->setValue(deviceUuid, ESP_UUID_LEN); - - uint8_t mfgPayload[6]; - mfgPayload[0] = 0xFF; - mfgPayload[1] = 0xFF; - memcpy(mfgPayload + 2, deviceUuid, 4); - NimBLEAdvertisementData scanResp; - scanResp.setCompleteServices(NimBLEUUID(SERVICE_UUID)); - scanResp.setManufacturerData(std::string((char*)mfgPayload, 6)); - NimBLEDevice::getAdvertising()->setScanResponseData(scanResp); Serial.println("OK"); Serial.println("UUID stored in NVS"); } else {