Merge branch 'feature/aes-ecdh-ble' into main

- AES-256-GCM + ECDH key exchange vervangt HMAC-SHA256 (issue #2)
- NVS namespace 'hmac' -> 'syl', key 'key' -> 'psk'
- ECDH-UUID characteristic toegevoegd voor key agreement
- Android: PSK key exchange via USB CDC, ECDH handshake in BleRelayManager
- Conflictoplossingen:
  - pUuidChar + pEcdhChar beide globaal, INPUT voor touch pins
  - client.cpp: serverFound + MyScanCallbacks behouden voor ECDH compat
  - SettingsActivity.kt: PSK detectie + debug log gecombineerd
  - version.txt: HEAD versie (20/5) behouden
  - version.h: verwijderd (auto-gegenereerd)
This commit is contained in:
eddy 2026-08-03 23:45:15 +02:00
commit a242bfa58a
9 changed files with 797 additions and 171 deletions

View file

@ -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)
@ -120,8 +147,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 +215,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 +296,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 +311,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)
@ -309,6 +339,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 +360,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 +397,185 @@ class BleRelayManager(
value: ByteArray
) {
if (characteristic.uuid == OTA_UUID && value.size >= 1) {
val status = value[0].toInt() and 0xFF
val seq = if (value.size >= 5) {
val statusVal = value[0].toInt() and 0xFF
val seqVal = if (value.size >= 5) {
((value[1].toInt() and 0xFF) shl 24) or
((value[2].toInt() and 0xFF) shl 16) or
((value[3].toInt() and 0xFF) shl 8) or
(value[4].toInt() and 0xFF)
} else 0
val statusName = when (status) { 0x00 -> "ACK"; 0x01 -> "NAK"; 0x10 -> "COMPLETE"; 0x11 -> "ERROR"; else -> "UNKNOWN(0x${status.toString(16)})" }
Log.d(TAG, "OTA notify: status=$statusName seq=$seq raw=${value.joinToString("") { "%02X".format(it) }}")
handler.post { otaNotifyCallback?.onOtaNotify(status, seq) }
val statusName = when (statusVal) { 0x00 -> "ACK"; 0x01 -> "NAK"; 0x10 -> "COMPLETE"; 0x11 -> "ERROR"; else -> "UNKNOWN(0x${statusVal.toString(16)})" }
Log.d(TAG, "OTA notify: status=$statusName seq=$seqVal raw=${value.joinToString("") { "%02X".format(it) }}")
handler.post { otaNotifyCallback?.onOtaNotify(statusVal, seqVal) }
} else if (characteristic.uuid == ECDH_UUID) {
ecdhNotifyBuf = value.copyOf()
ecdhDeferred?.complete(true)
Log.d(TAG, "ECDH notify received (${value.size} bytes)")
}
}
}
private fun loadState() {
val prefs = PrefsHelper.getPrefs(context)
val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null)
hmacKey = hexKey?.let { hexStringToBytes(it) }
val hexUuid = PrefsHelper.getServerUuid(context)
?: prefs.getString(PrefsHelper.PREF_UUID, null)
deviceUuid = hexUuid?.let { hexStringToBytes(it) }
seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX)
Log.d(TAG, "HMAC key loaded: ${hmacKey?.size ?: "none"} bytes, UUID: ${deviceUuid?.size ?: "none"} bytes, seq=$seq")
@SuppressLint("MissingPermission")
private fun startEcdhHandshake(gatt: BluetoothGatt) {
val char = ecdhCharacteristic
if (char == null) {
Log.w(TAG, "ECDH characteristic not found")
handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) }
gatt.disconnect()
return
}
if (!gatt.setCharacteristicNotification(char, true)) {
Log.w(TAG, "ECDH: setCharacteristicNotification failed")
gatt.disconnect()
return
}
val cccd = char.getDescriptor(OTA_CCCD_UUID)
if (cccd != null) {
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(cccd)
}
val myPsk = psk ?: run {
Log.w(TAG, "ECDH: no PSK")
gatt.disconnect()
return
}
try {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val keyPair = kpg.generateKeyPair()
val pubKey = keyPair.public as ECPublicKey
val pubBytes = encodeEcPubKey(pubKey)
if (pubBytes.size != ECDH_PUBKEY_LEN) {
Log.e(TAG, "ECDH: pubkey wrong size ${pubBytes.size}")
gatt.disconnect()
return
}
val handshake = ByteArray(ECDH_PKT_SIZE)
System.arraycopy(pubBytes, 0, handshake, 0, ECDH_PUBKEY_LEN)
val sig = hmacSha256(pubBytes, myPsk)
System.arraycopy(sig, 0, handshake, ECDH_PUBKEY_LEN, 32)
ecdhDeferred = CompletableDeferred()
char.value = handshake
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
if (!gatt.writeCharacteristic(char)) {
Log.w(TAG, "ECDH: write pubkey failed")
gatt.disconnect()
return
}
Log.d(TAG, "ECDH: pubkey written, waiting for server...")
Thread {
val start = System.currentTimeMillis()
while (!ecdhDeferred!!.isDone() && System.currentTimeMillis() - start < ECDH_TIMEOUT_MS) {
Thread.sleep(50)
}
if (!ecdhDeferred!!.isDone()) {
Log.w(TAG, "ECDH: notify timeout")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val resp = ecdhNotifyBuf
if (resp.size != ECDH_PKT_SIZE) {
Log.w(TAG, "ECDH: bad response size ${resp.size}")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val serverPub = resp.copyOfRange(0, ECDH_PUBKEY_LEN)
val serverSig = resp.copyOfRange(ECDH_PUBKEY_LEN, ECDH_PKT_SIZE)
val expectedSig = hmacSha256(serverPub, myPsk)
if (!serverSig.contentEquals(expectedSig)) {
Log.w(TAG, "ECDH: server pubkey HMAC mismatch")
handler.post {
callbacks.onStatusChanged(BleStatus.CommError)
gatt.disconnect()
}
return@Thread
}
val ka = KeyAgreement.getInstance("ECDH")
ka.init(keyPair.private)
val spec = java.security.spec.ECPublicKeySpec(
java.security.spec.ECPoint(
java.math.BigInteger(1, serverPub.copyOfRange(1, 33)),
java.math.BigInteger(1, serverPub.copyOfRange(33, 65))
),
java.security.spec.ECParameterSpec(
java.security.spec.EllipticCurve(
java.security.spec.ECFieldFp(
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", 16)
),
java.math.BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC", 16),
java.math.BigInteger("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B", 16)
),
java.security.spec.ECPoint(
java.math.BigInteger("6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296", 16),
java.math.BigInteger("4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5", 16)
),
java.math.BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16),
1
)
)
val serverKey = java.security.KeyFactory.getInstance("EC").generatePublic(spec)
ka.doPhase(serverKey, true)
val shared = ka.generateSecret()
val md = java.security.MessageDigest.getInstance("SHA-256")
aesKey = md.digest(shared)
ecdhDone = true
seq = 1
handler.post {
Log.d(TAG, "ECDH: handshake complete, reading version...")
val service = gatt.getService(SERVICE_UUID)
val versionChar = service?.getCharacteristic(VERSION_UUID)
if (versionChar != null) {
val ok = gatt.readCharacteristic(versionChar)
if (!ok) {
Log.w(TAG, "readCharacteristic(VERSION) returned false")
callbacks.onFirmwareVersion("", appVersion)
callbacks.onStatusChanged(BleStatus.Connected)
}
} else {
Log.d(TAG, "VERSION characteristic not found on ESP")
callbacks.onFirmwareVersion("", appVersion)
callbacks.onStatusChanged(BleStatus.Connected)
}
}
}.start()
} catch (e: Exception) {
Log.e(TAG, "ECDH: exception", e)
gatt.disconnect()
}
}
private fun hexStringToBytes(hex: String): ByteArray? {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun encodeEcPubKey(pubKey: ECPublicKey): ByteArray {
val x = pubKey.w.affineX.toByteArray()
val y = pubKey.w.affineY.toByteArray()
val xPadded = ByteArray(32)
val yPadded = ByteArray(32)
System.arraycopy(x, maxOf(0, x.size - 32), xPadded, maxOf(0, 32 - x.size), minOf(x.size, 32))
System.arraycopy(y, maxOf(0, y.size - 32), yPadded, maxOf(0, 32 - y.size), minOf(y.size, 32))
val out = ByteArray(65)
out[0] = 0x04
System.arraycopy(xPadded, 0, out, 1, 32)
System.arraycopy(yPadded, 0, out, 33, 32)
return out
}
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
@ -416,16 +584,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
}
}

View file

@ -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"

View file

@ -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) }
}