Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be06b7469 | ||
|
|
a242bfa58a | ||
|
|
2b7c2b8450 | ||
|
|
d6f1062a44 |
9 changed files with 833 additions and 171 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,51 @@ 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 var ecdhCccdPending = false
|
||||
|
||||
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)
|
||||
|
|
@ -120,8 +148,8 @@ class BleRelayManager(
|
|||
return
|
||||
}
|
||||
loadState()
|
||||
if (hmacKey == null || deviceUuid == null) {
|
||||
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
|
||||
if (psk == null || deviceUuid == null) {
|
||||
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
|
||||
return
|
||||
}
|
||||
isScanning = true
|
||||
|
|
@ -188,7 +216,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
|
||||
|
|
@ -269,9 +297,9 @@ class BleRelayManager(
|
|||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) {
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
if (hmacKey == null) {
|
||||
Log.w(TAG, "GATT connected but no key -- disconnecting")
|
||||
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
|
||||
if (psk == null) {
|
||||
Log.w(TAG, "GATT connected but no PSK -- disconnecting")
|
||||
handler.post { callbacks.onStatusChanged(BleStatus.NoKey) }
|
||||
gatt.disconnect()
|
||||
return
|
||||
}
|
||||
|
|
@ -284,6 +312,10 @@ 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)
|
||||
|
|
@ -309,6 +341,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) {
|
||||
|
|
@ -329,21 +362,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)
|
||||
|
|
@ -379,35 +399,210 @@ 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)")
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@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 {
|
||||
|
|
@ -416,16 +611,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
|
||||
|
|
@ -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 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()
|
||||
|
|
@ -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 HMAC key")
|
||||
Log.d(TAG, "ESP has no keys, using existing PSK")
|
||||
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("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
|
||||
}
|
||||
}
|
||||
|
|
@ -824,7 +824,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 |
237
src/client.cpp
237
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;
|
||||
|
|
@ -57,8 +60,23 @@ 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;
|
||||
|
|
@ -69,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;
|
||||
|
|
@ -83,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() {
|
||||
|
|
@ -114,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() {
|
||||
|
|
@ -157,10 +277,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;
|
||||
}
|
||||
|
||||
|
|
@ -174,17 +303,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) {
|
||||
|
|
@ -208,7 +337,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_PULLUP);
|
||||
pinMode(TOUCH2_PIN, INPUT_PULLUP);
|
||||
|
|
@ -244,12 +373,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) {
|
||||
|
|
@ -267,15 +396,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");
|
||||
bleReady = true;
|
||||
|
|
@ -288,13 +417,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 {
|
||||
|
|
@ -304,13 +433,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) {
|
||||
|
|
@ -348,9 +477,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;
|
||||
|
||||
|
|
@ -390,21 +516,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();
|
||||
|
|
@ -448,13 +574,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;
|
||||
}
|
||||
}
|
||||
};
|
||||
224
src/main.cpp
224
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,15 +41,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 "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 *pUuidChar;
|
||||
NimBLECharacteristic *pEcdhChar;
|
||||
bool relay1State = false;
|
||||
bool relay2State = false;
|
||||
|
||||
|
|
@ -59,6 +60,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;
|
||||
|
|
@ -78,12 +92,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;
|
||||
|
|
@ -107,6 +115,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,
|
||||
|
|
@ -125,6 +136,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)",
|
||||
|
|
@ -136,36 +150,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;
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +198,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;
|
||||
|
|
@ -181,8 +206,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -327,6 +352,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;
|
||||
|
|
@ -338,19 +420,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) {
|
||||
|
|
@ -377,16 +459,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) {
|
||||
|
|
@ -402,8 +484,13 @@ void setup() {
|
|||
ESP_LOGD(TAG, "Generated new UUID");
|
||||
}
|
||||
|
||||
pinMode(TOUCH1_PIN, INPUT_PULLUP);
|
||||
pinMode(TOUCH2_PIN, INPUT_PULLUP);
|
||||
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);
|
||||
pinMode(RELAY1_PIN, OUTPUT);
|
||||
pinMode(RELAY2_PIN, OUTPUT);
|
||||
|
|
@ -425,6 +512,12 @@ 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
|
||||
|
|
@ -443,6 +536,8 @@ void setup() {
|
|||
);
|
||||
pOtaChar->setCallbacks(new OtaCharacteristicCallbacks());
|
||||
|
||||
pService->start();
|
||||
|
||||
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
|
||||
pAdvertising->setName("SylDa_Server");
|
||||
|
||||
|
|
@ -463,10 +558,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.");
|
||||
|
|
@ -480,13 +575,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 {
|
||||
|
|
@ -501,29 +596,36 @@ 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();
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue