Replace HMAC-SHA256 with AES-256-GCM + ECDH key exchange for BLE relay commands (issue #2)
- New: src/ecdh_helper.h — ECDH keypair gen, shared secret, pubkey HMAC sign/verify - Server (src/main.cpp): ECDH handshake callback, AES-GCM decrypt, PSK from NVS - Client (src/main.cpp→client.cpp): ECDH after connect, AES-GCM encrypt, PSK from NVS - Android: ECDH (KeyAgreement) + AES-GCM (Cipher) in BleRelayManager.kt - PSK provisioned via USB CDC SET_KEY (SettingsActivity.kt) - PrefsHelper: hmac_key → psk - Bumped versions: server=18, client=4 - NVS namespace: hmac→syl, key:hmac_key→psk - BLE ECDH characteristic: beb5483e-...-f26dc (WRITE_NR|NOTIFY)
This commit is contained in:
parent
8b2dfcbfd0
commit
d6f1062a44
11 changed files with 788 additions and 173 deletions
|
|
@ -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<String>()
|
||||
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<Boolean>? = null
|
||||
private var ecdhNotifyBuf: ByteArray = ByteArray(0)
|
||||
|
||||
private class CompletableDeferred<T> {
|
||||
private var result: T? = null
|
||||
private var completed = false
|
||||
@Synchronized fun complete(value: T) { result = value; completed = true }
|
||||
@Synchronized fun isDone(): Boolean = completed
|
||||
@Synchronized fun get(): T? = result
|
||||
}
|
||||
|
||||
interface OtaNotifyCallback {
|
||||
fun onOtaNotify(status: Int, seq: Int)
|
||||
|
|
@ -107,7 +134,7 @@ class BleRelayManager(
|
|||
return
|
||||
}
|
||||
loadState()
|
||||
if (hmacKey == null || deviceUuid == null) {
|
||||
if (psk == null || deviceUuid == null) {
|
||||
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
|
||||
return
|
||||
}
|
||||
|
|
@ -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,8 +283,8 @@ 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")
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
63
docs/plan-issue-2-aes-ecdh.md
Normal file
63
docs/plan-issue-2-aes-ecdh.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Plan: Issue #2 — AES-256-GCM + ECDH + PSK voor BLE relay commands
|
||||
|
||||
## Beveiligingsmodel
|
||||
|
||||
### Provisioning (eenmalig, USB CDC)
|
||||
Android genereert 32B PSK (pre-shared key) en provisioned beide ESP's:
|
||||
- `SET_KEY` → Server ESP (NVS `syl/psk`)
|
||||
- `SET_KEY` → Client ESP (NVS `syl/psk`)
|
||||
|
||||
### BLE ECDH handshake (elke sessie)
|
||||
```
|
||||
Client Server
|
||||
|── pubkey(65B) || HMAC(PSK,pubkey)(32B) ─────>| verify HMAC met PSK
|
||||
| | genereer eigen keypair
|
||||
|<─ pubkey(65B) || HMAC(PSK,pubkey)(32B) ──────| verify HMAC met PSK
|
||||
| |
|
||||
AES_KEY = SHA-256(ECDH_shared_secret) AES_KEY = SHA-256(ECDH_shared_secret)
|
||||
```
|
||||
|
||||
- PSK verlaat nooit het apparaat over BLE — alleen HMAC ervan
|
||||
- Zonder PSK kan MITM geen geldige HMAC voor fake pubkey produceren
|
||||
- Per-sessie AES keys → perfect forward secrecy
|
||||
- PSK gelekt? → her-provisionen via USB CDC
|
||||
|
||||
## Packet structuren
|
||||
|
||||
### ECDH handshake packet (97 bytes)
|
||||
```
|
||||
pubkey[65] || HMAC-SHA256(PSK, pubkey)[32]
|
||||
```
|
||||
BLE characteristic: `beb5483e-...-f26dc`, properties `WRITE_NR | NOTIFY`
|
||||
|
||||
### Relay command (29 bytes, AES-256-GCM)
|
||||
```
|
||||
seq(4) || nonce(8) || ciphertext_cmd(1) || tag(16)
|
||||
```
|
||||
Nonce voor GCM = `seq(4) || random(8)` = 12 bytes.
|
||||
Sequence > lastSeq voor replay protection per sessie.
|
||||
|
||||
## NVS / SharedPreferences
|
||||
|
||||
| Opslag | Sleutel | Waarde |
|
||||
|---|---|---|
|
||||
| ESP NVS `syl` | `psk` | 32B PSK |
|
||||
| ESP NVS `syl` | `uuid` | 16B device UUID |
|
||||
| Android Prefs | `psk` | 64-char hex PSK |
|
||||
| Android Prefs | `device_uuid` | 32-char hex UUID |
|
||||
|
||||
## Bestandswijzigingen
|
||||
|
||||
### Nieuw
|
||||
- `src/ecdh_helper.h` — ECDH keypair generatie + shared secret (mbedtls/ecdh.h)
|
||||
|
||||
### Gewijzigd
|
||||
- `src/main.cpp` — HMAC → ECDH handshake + AES-GCM decrypt; PSK uit NVS
|
||||
- `src/client.cpp` — HMAC → ECDH handshake + AES-GCM encrypt; PSK uit NVS
|
||||
- `android/.../BleRelayManager.kt` — HMAC → ECDH (KeyAgreement) + AES-GCM (Cipher)
|
||||
- `android/.../SettingsActivity.kt` — SET_KEY schrijft voortaan PSK
|
||||
- `android/.../PrefsHelper.kt` — `hmac_key` → `psk`
|
||||
- `version.txt` — bump server + client versies
|
||||
|
||||
## Werkt NIET met oude firmware/app
|
||||
Geen backward compatibility: schone break. HMAC packets worden niet meer geaccepteerd.
|
||||
BIN
icon.png
Normal file
BIN
icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
34
icon.svg
Normal file
34
icon.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Achtergrond: Dark Antraciet Squircle -->
|
||||
<rect x="32" y="32" width="448" height="448" rx="105" ry="105" fill="#121212" />
|
||||
<rect x="32" y="32" width="448" height="448" rx="105" ry="105" fill="none" stroke="#2A2A32" stroke-width="4" />
|
||||
|
||||
<!-- Centrale Icon Groep met Gloed -->
|
||||
<g filter="url(#glow)">
|
||||
<!-- Power Cirkel (geopend aan de bovenkant) -->
|
||||
<path d="M 186 210 A 110 110 0 1 0 326 210"
|
||||
fill="none"
|
||||
stroke="#00E5FF"
|
||||
stroke-width="28"
|
||||
stroke-linecap="round" />
|
||||
|
||||
<!-- Bluetooth Pijl / Power Pin -->
|
||||
<path d="M 256 120 L 256 260 M 256 120 L 285 145 L 240 180 L 270 205 L 256 216"
|
||||
fill="none"
|
||||
stroke="#00E5FF"
|
||||
stroke-width="24"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</g>
|
||||
|
||||
<!-- Twee status-stippen voor Relais 1 & 2 -->
|
||||
<circle cx="216" cy="370" r="10" fill="#00E5FF" opacity="0.8" />
|
||||
<circle cx="296" cy="370" r="10" fill="#00E5FF" opacity="0.8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
226
src/client.cpp
226
src/client.cpp
|
|
@ -2,12 +2,13 @@
|
|||
#include <NimBLEDevice.h>
|
||||
#include <FastLED.h>
|
||||
|
||||
#include <mbedtls/md.h>
|
||||
#include <mbedtls/gcm.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
#include <esp_log.h>
|
||||
#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();
|
||||
}
|
||||
|
||||
|
|
|
|||
114
src/ecdh_helper.h
Normal file
114
src/ecdh_helper.h
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <mbedtls/ecdh.h>
|
||||
#include <mbedtls/entropy.h>
|
||||
#include <mbedtls/ctr_drbg.h>
|
||||
#include <mbedtls/md.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
|
||||
#define ECDH_PUBKEY_LEN 65
|
||||
#define AES_KEY_LEN 32
|
||||
#define PSK_LEN 32
|
||||
#define AES_PKT_SIZE 29
|
||||
#define ECDH_PKT_SIZE 97 // pubkey(65) + HMAC(32)
|
||||
|
||||
class CryptoECDH {
|
||||
private:
|
||||
mbedtls_ecdh_context ecdh;
|
||||
mbedtls_entropy_context entropy;
|
||||
mbedtls_ctr_drbg_context ctr_drbg;
|
||||
bool initialized = false;
|
||||
|
||||
CryptoECDH(const CryptoECDH&) = delete;
|
||||
CryptoECDH& operator=(const CryptoECDH&) = delete;
|
||||
|
||||
public:
|
||||
CryptoECDH() = default;
|
||||
|
||||
uint8_t public_key[ECDH_PUBKEY_LEN];
|
||||
size_t pub_len = 0;
|
||||
uint8_t shared_secret[32];
|
||||
uint8_t aes_key[AES_KEY_LEN];
|
||||
|
||||
bool init() {
|
||||
mbedtls_ecdh_init(&ecdh);
|
||||
mbedtls_entropy_init(&entropy);
|
||||
mbedtls_ctr_drbg_init(&ctr_drbg);
|
||||
|
||||
const char *pers = "sylda_ecdh";
|
||||
int ret = mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy,
|
||||
(const unsigned char *)pers, strlen(pers));
|
||||
if (ret != 0) {
|
||||
mbedtls_ecdh_free(&ecdh);
|
||||
mbedtls_entropy_free(&entropy);
|
||||
mbedtls_ctr_drbg_free(&ctr_drbg);
|
||||
return false;
|
||||
}
|
||||
|
||||
ret = mbedtls_ecp_group_load(&ecdh.grp, MBEDTLS_ECP_DP_SECP256R1);
|
||||
if (ret != 0) {
|
||||
mbedtls_ecdh_free(&ecdh);
|
||||
mbedtls_entropy_free(&entropy);
|
||||
mbedtls_ctr_drbg_free(&ctr_drbg);
|
||||
return false;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool generateKeys() {
|
||||
if (!initialized) return false;
|
||||
int ret = mbedtls_ecdh_gen_public(&ecdh.grp, &ecdh.d, &ecdh.Q,
|
||||
mbedtls_ctr_drbg_random, &ctr_drbg);
|
||||
if (ret != 0) return false;
|
||||
|
||||
ret = mbedtls_ecp_point_write_binary(&ecdh.grp, &ecdh.Q,
|
||||
MBEDTLS_ECP_PF_UNCOMPRESSED,
|
||||
&pub_len, public_key, ECDH_PUBKEY_LEN);
|
||||
return (ret == 0 && pub_len == ECDH_PUBKEY_LEN);
|
||||
}
|
||||
|
||||
bool computeSharedSecret(const uint8_t *peerPubKey, size_t peerPubLen) {
|
||||
if (!initialized || peerPubLen != ECDH_PUBKEY_LEN) return false;
|
||||
|
||||
int ret = mbedtls_ecp_point_read_binary(&ecdh.grp, &ecdh.Qp,
|
||||
peerPubKey, peerPubLen);
|
||||
if (ret != 0) return false;
|
||||
|
||||
size_t olen;
|
||||
ret = mbedtls_ecdh_compute_shared(&ecdh.grp, &ecdh.z, &ecdh.Qp, &ecdh.d,
|
||||
mbedtls_ctr_drbg_random, &ctr_drbg);
|
||||
if (ret != 0) return false;
|
||||
|
||||
ret = mbedtls_mpi_write_binary(&ecdh.z, shared_secret, 32);
|
||||
if (ret != 0) return false;
|
||||
|
||||
mbedtls_sha256(shared_secret, 32, aes_key, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static void signPubkey(const uint8_t *psk, const uint8_t *pubkey, uint8_t *hmacOut) {
|
||||
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
mbedtls_md_hmac(mdInfo, psk, PSK_LEN, pubkey, ECDH_PUBKEY_LEN, hmacOut);
|
||||
}
|
||||
|
||||
static bool verifyPubkey(const uint8_t *psk, const uint8_t *pubkey, const uint8_t *hmac) {
|
||||
uint8_t computed[32];
|
||||
signPubkey(psk, pubkey, computed);
|
||||
return memcmp(computed, hmac, 32) == 0;
|
||||
}
|
||||
|
||||
void free() {
|
||||
if (initialized) {
|
||||
mbedtls_ecdh_free(&ecdh);
|
||||
mbedtls_ctr_drbg_free(&ctr_drbg);
|
||||
mbedtls_entropy_free(&entropy);
|
||||
memset(shared_secret, 0, sizeof(shared_secret));
|
||||
memset(aes_key, 0, sizeof(aes_key));
|
||||
initialized = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
211
src/main.cpp
211
src/main.cpp
|
|
@ -3,7 +3,7 @@
|
|||
#include <NimBLEAdvertising.h>
|
||||
#include <FastLED.h>
|
||||
|
||||
#include <mbedtls/md.h>
|
||||
#include <mbedtls/gcm.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <Preferences.h>
|
||||
#include <esp_ota_ops.h>
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
#include <nvs_flash.h>
|
||||
#include <esp_log.h>
|
||||
#include "version.h"
|
||||
#include "ecdh_helper.h"
|
||||
|
||||
static const char *TAG = "server";
|
||||
|
||||
|
|
@ -31,8 +32,8 @@ CRGB leds[NUM_LEDS];
|
|||
#define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9"
|
||||
#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db"
|
||||
#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da"
|
||||
#define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc"
|
||||
|
||||
#define HMAC_PKT_SIZE 37
|
||||
#define OTA_CHUNK_SIZE 500
|
||||
#define OTA_CMD_START 0x10
|
||||
#define OTA_CMD_END 0x11
|
||||
|
|
@ -40,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<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
|
||||
|
||||
preferences.begin(NVS_NAMESPACE, true);
|
||||
size_t len = preferences.getBytes(NVS_KEY_NAME, hmacKey, HMAC_KEY_LEN);
|
||||
size_t pskLen = preferences.getBytes(NVS_PSK_NAME, psk, PSK_LEN);
|
||||
size_t uuidLen = preferences.getBytes(NVS_UUID_NAME, deviceUuid, ESP_UUID_LEN);
|
||||
preferences.end();
|
||||
|
||||
if (len == HMAC_KEY_LEN) {
|
||||
keyProvisioned = true;
|
||||
ESP_LOGI(TAG, "HMAC key loaded from NVS");
|
||||
if (pskLen == PSK_LEN) {
|
||||
pskProvisioned = true;
|
||||
ESP_LOGI(TAG, "PSK loaded from NVS");
|
||||
} else {
|
||||
keyProvisioned = false;
|
||||
ESP_LOGW(TAG, "No HMAC key — connect USB and run SET_KEY");
|
||||
pskProvisioned = false;
|
||||
ESP_LOGW(TAG, "No PSK — connect USB and run SET_KEY");
|
||||
}
|
||||
|
||||
if (uuidLen == ESP_UUID_LEN) {
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
server=17
|
||||
client=3
|
||||
server=18
|
||||
client=4
|
||||
|
|
|
|||
Loading…
Reference in a new issue