diff --git a/.superpowers/brainstorm/124687-1785870282/content/broker_flow.html b/.superpowers/brainstorm/124687-1785870282/content/broker_flow.html new file mode 100644 index 0000000..209e217 --- /dev/null +++ b/.superpowers/brainstorm/124687-1785870282/content/broker_flow.html @@ -0,0 +1,53 @@ +

Voorgestelde Flow: De "Server-as-Broker" methode

+

Gebruik de SylDa_Server als ontmoetingsplaats voor versie-informatie

+ +
+
Interactie Diagram
+
+
// 1. Client meldt zich aan
+ [Client] --(Write version: "5")--> [Server: ClientStatusChar] + [Client] --(Subscribe)------------> [Server: ClientControlChar] + +
// 2. App checkt status
+ [App] --(Read ClientStatus)----> [Server] + App denkt: "Client heeft v5, ik heb v6. Update nodig!" + +
// 3. App start de dans
+ [App] --(Write: "START_OTA")---> [Server: ClientControlChar] + [Server] --(Notify)---------------> [Client] + +
// 4. De wissel
+ Client disconnect van Server -> Start Advertising + App disconnect van Server -> Scant naar Client OTA + +
// 5. De eigenlijke OTA
+ [App] --(Direct BLE OTA)-------> [Client (nu in Server mode)] +
+
+ +
+

Technische details om te bespreken:

+
+
+
1
+
+

UUID Tracking

+

Aangezien de Server 2 clients ondersteunt, moet de Server de versie per UUID opslaan zodat de App weet welke client welke versie heeft.

+
+
+
+
2
+
+

Beveiliging (HMAC/AES)

+

Moet de "START_OTA" opdracht ook versleuteld zijn? (Waarschijnlijk wel, om te voorkomen dat een vreemde de clients platlegt).

+
+
+
+
3
+
+

Auto-reconnect

+

Na de update (of bij falen/timeout) moet de Client weer terug in zijn normale rol vallen en de Server zoeken.

+
+
+
+
diff --git a/.superpowers/brainstorm/124687-1785870282/content/ota_options.html b/.superpowers/brainstorm/124687-1785870282/content/ota_options.html new file mode 100644 index 0000000..fe868c1 --- /dev/null +++ b/.superpowers/brainstorm/124687-1785870282/content/ota_options.html @@ -0,0 +1,48 @@ +

Ontwerpkeuzes BLE OTA voor de Client

+

Hoe krijgt de Client (ESP32) de nieuwe firmware?

+ +
+
+
A
+
+

Android App Direct

+

De Client ESP32 start een tijdelijke BLE Server (OTA Service). De Android app verbindt direct met de Client om de firmware te pushen.

+
Voordeel: Simpel, hergebruikt Android OTA code.
+
Nadeel: Handmatige actie nodig op Client (bijv. knop indrukken).
+
+
+ +
+
B
+
+

Server Proxy / Relay

+

De Android app stuurt de firmware naar de SylDa_Server. De Server stuurt het door naar de Client via de bestaande BLE verbinding.

+
Voordeel: Geen directe verbinding Android-Client nodig.
+
Nadeel: Complexer, Server moet grote buffers hebben of flow control.
+
+
+ +
+
C
+
+

Client Pull (via Server)

+

De Server kondigt een update aan. De Client haalt de firmware "chunks" op bij de Server via een nieuwe OTA service op de Server.

+
Voordeel: Client heeft controle over tempo.
+
Nadeel: Server moet de firmware ergens opslaan (Flash/NVS/SPIFFS?) voordat hij het kan doorgeven.
+
+
+
+ +
+

Huidige Situatie

+
+
Architectuur
+
+
+[Android App] --(BLE OTA)--> [SylDa_Server (ESP32)]
+      |
+      ? --(BLE OTA?)--> [SylDa_Client (ESP32)]
+      
+
+
+
diff --git a/.superpowers/brainstorm/124687-1785870282/state/events b/.superpowers/brainstorm/124687-1785870282/state/events new file mode 100644 index 0000000..93cf1e4 --- /dev/null +++ b/.superpowers/brainstorm/124687-1785870282/state/events @@ -0,0 +1,2 @@ +{"type":"click","text":"3\n \n Auto-reconnect\n Na de update (of bij falen/timeout) moet de Client weer terug in zijn normale rol vallen en de Server zoeken.","choice":"reconnect","id":null,"timestamp":1785870849065} +{"type":"click","text":"3\n \n Auto-reconnect\n Na de update (of bij falen/timeout) moet de Client weer terug in zijn normale rol vallen en de Server zoeken.","choice":"reconnect","id":null,"timestamp":1785870849785} diff --git a/.superpowers/brainstorm/124687-1785870282/state/server-stopped b/.superpowers/brainstorm/124687-1785870282/state/server-stopped new file mode 100644 index 0000000..e14c9e8 --- /dev/null +++ b/.superpowers/brainstorm/124687-1785870282/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1785872682901} diff --git a/.superpowers/brainstorm/124687-1785870282/state/server.log b/.superpowers/brainstorm/124687-1785870282/state/server.log new file mode 100644 index 0000000..ac61bb5 --- /dev/null +++ b/.superpowers/brainstorm/124687-1785870282/state/server.log @@ -0,0 +1,7 @@ +{"type":"server-started","port":56787,"host":"0.0.0.0","url_host":"100.97.199.84","url":"http://100.97.199.84:56787","screen_dir":"/home/eddy/src/Project_SylDa/.superpowers/brainstorm/124687-1785870282/content","state_dir":"/home/eddy/src/Project_SylDa/.superpowers/brainstorm/124687-1785870282/state"} +{"type":"screen-added","file":"/home/eddy/src/Project_SylDa/.superpowers/brainstorm/124687-1785870282/content/ota_options.html"} +{"source":"user-event","type":"click","text":"A\n \n Android App Direct\n De Client ESP32 start een tijdelijke BLE Server (OTA Service). De Android app verbindt direct met de Client om de firmware te pushen.\n Voordeel: Simpel, hergebruikt Android OTA code.\n Nadeel: Handmatige actie nodig op Client (bijv. knop indrukken).","choice":"android-direct","id":null,"timestamp":1785870382036} +{"type":"screen-added","file":"/home/eddy/src/Project_SylDa/.superpowers/brainstorm/124687-1785870282/content/broker_flow.html"} +{"source":"user-event","type":"click","text":"3\n \n Auto-reconnect\n Na de update (of bij falen/timeout) moet de Client weer terug in zijn normale rol vallen en de Server zoeken.","choice":"reconnect","id":null,"timestamp":1785870849065} +{"source":"user-event","type":"click","text":"3\n \n Auto-reconnect\n Na de update (of bij falen/timeout) moet de Client weer terug in zijn normale rol vallen en de Server zoeken.","choice":"reconnect","id":null,"timestamp":1785870849785} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/124687-1785870282/state/server.pid b/.superpowers/brainstorm/124687-1785870282/state/server.pid new file mode 100644 index 0000000..24f92f7 --- /dev/null +++ b/.superpowers/brainstorm/124687-1785870282/state/server.pid @@ -0,0 +1 @@ +124706 diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt index f74686d..7758380 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -22,8 +22,10 @@ import androidx.core.app.ActivityCompat import java.nio.ByteBuffer import java.nio.ByteOrder import java.security.KeyPairGenerator +import java.security.KeyFactory import java.security.SecureRandom import java.security.spec.ECGenParameterSpec +import java.security.spec.X509EncodedKeySpec import java.security.interfaces.ECPublicKey import java.util.UUID import javax.crypto.Cipher @@ -61,6 +63,25 @@ class BleRelayManager( private const val ECDH_PKT_SIZE = 97 private const val BLE_MTU = 512 private const val ECDH_TIMEOUT_MS = 5000L + + private val SECP256R1_SPKI_HEADER = byteArrayOf( + 0x30.toByte(), 0x59.toByte(), 0x30.toByte(), 0x13.toByte(), + 0x06.toByte(), 0x07.toByte(), 0x2A.toByte(), 0x86.toByte(), + 0x48.toByte(), 0xCE.toByte(), 0x3D.toByte(), 0x02.toByte(), + 0x01.toByte(), 0x06.toByte(), 0x08.toByte(), 0x2A.toByte(), + 0x86.toByte(), 0x48.toByte(), 0xCE.toByte(), 0x3D.toByte(), + 0x03.toByte(), 0x01.toByte(), 0x07.toByte(), 0x03.toByte(), + 0x42.toByte(), 0x00.toByte() + ) + + private fun decodeEcPublicKey(uncompressedKey: ByteArray): ECPublicKey { + require(uncompressedKey.size == ECDH_PUBKEY_LEN && uncompressedKey[0] == 0x04.toByte()) { + "Invalid uncompressed EC key format" + } + val spkiEncoded = SECP256R1_SPKI_HEADER + uncompressedKey + return KeyFactory.getInstance("EC") + .generatePublic(X509EncodedKeySpec(spkiEncoded)) as ECPublicKey + } } private var bluetoothGatt: BluetoothGatt? = null @@ -84,6 +105,16 @@ class BleRelayManager( private var ecdhNotifyBuf: ByteArray = ByteArray(0) private var ecdhCccdPending = false + @Synchronized + private fun setEcdhNotifyBuf(value: ByteArray) { + ecdhNotifyBuf = value.copyOf() + } + + @Synchronized + private fun getEcdhNotifyBuf(): ByteArray { + return ecdhNotifyBuf.copyOf() + } + interface OtaNotifyCallback { fun onOtaNotify(status: Int, seq: Int) } @@ -404,7 +435,7 @@ class BleRelayManager( 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() + setEcdhNotifyBuf(value) ecdhDeferred?.complete(true) Log.d(TAG, "ECDH notify received (${value.size} bytes)") } @@ -506,7 +537,7 @@ class BleRelayManager( return@Thread } - val resp = ecdhNotifyBuf + val resp = getEcdhNotifyBuf() if (resp.size != ECDH_PKT_SIZE) { Log.w(TAG, "ECDH: bad response size ${resp.size}") handler.post { @@ -530,28 +561,7 @@ class BleRelayManager( 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) + val serverKey = decodeEcPublicKey(serverPub) ka.doPhase(serverKey, true) val shared = ka.generateSecret() @@ -606,13 +616,13 @@ class BleRelayManager( } private fun loadState() { - val prefs = PrefsHelper.getPrefs(context) - val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) + PrefsHelper.migrateCredentialsIfNeeded(context) + val hexKey = PrefsHelper.getPsk(context) psk = hexKey?.let { hexStringToBytes(it) } val hexUuid = PrefsHelper.getServerUuid(context) - ?: prefs.getString(PrefsHelper.PREF_UUID, null) + ?: PrefsHelper.getDeviceCredentialUuid(context) deviceUuid = hexUuid?.let { hexStringToBytes(it) } - seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX) + seq = PrefsHelper.getPrefs(context).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") } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt b/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt index fdc6c45..f67d6c7 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/PrefsHelper.kt @@ -3,12 +3,16 @@ package nl.eddydevink.sylda import android.content.Context import android.content.SharedPreferences +import android.util.Log import androidx.appcompat.app.AppCompatDelegate +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey object PrefsHelper { private const val TAG = "PrefsHelper" const val PREFS_NAME = "relay_prefs" + private const val ENCRYPTED_PREFS_NAME = "encrypted_prefs" const val PREF_KEY = "psk" const val PREF_UUID = "device_uuid" const val PREF_SEQ = "seq_counter" @@ -20,6 +24,49 @@ object PrefsHelper { const val KEY_CLIENT_UUID = "clientUuid" const val SEQ_MAX = 1_000_000 + private var masterKey: MasterKey? = null + + private fun getMasterKey(context: Context): MasterKey { + return masterKey ?: run { + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build().also { masterKey = it } + } + } + + fun getEncryptedPrefs(context: Context): SharedPreferences { + return EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + getMasterKey(context), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + fun migrateCredentialsIfNeeded(context: Context) { + val plainPrefs = getPrefs(context) + val encPrefs = getEncryptedPrefs(context) + + val legacyKey = plainPrefs.getString(PREF_KEY, null) + val legacyUuid = plainPrefs.getString(PREF_UUID, null) + + if (legacyKey != null && !encPrefs.contains(PREF_KEY)) { + Log.i(TAG, "Migrating credentials to encrypted storage") + encPrefs.edit().apply { + putString(PREF_KEY, legacyKey) + if (legacyUuid != null) putString(PREF_UUID, legacyUuid) + apply() + } + plainPrefs.edit().apply { + remove(PREF_KEY) + remove(PREF_UUID) + apply() + } + Log.i(TAG, "Credentials migration complete") + } + } + fun getDeviceMode(context: Context): String = getPrefs(context).getString(KEY_DEVICE_MODE, "server") ?: "server" fun setDeviceMode(context: Context, mode: String) = getPrefs(context).edit().putString(KEY_DEVICE_MODE, mode).apply() fun getTransportMode(context: Context): String = getPrefs(context).getString("transportMode", "ble") ?: "ble" @@ -29,6 +76,11 @@ object PrefsHelper { fun getClientUuid(context: Context): String? = getPrefs(context).getString(KEY_CLIENT_UUID, null) fun setClientUuid(context: Context, uuid: String) = getPrefs(context).edit().putString(KEY_CLIENT_UUID, uuid).apply() + fun getPsk(context: Context): String? = getEncryptedPrefs(context).getString(PREF_KEY, null) + fun setPsk(context: Context, psk: String) = getEncryptedPrefs(context).edit().putString(PREF_KEY, psk).apply() + fun getDeviceCredentialUuid(context: Context): String? = getEncryptedPrefs(context).getString(PREF_UUID, null) + fun setDeviceCredentialUuid(context: Context, uuid: String) = getEncryptedPrefs(context).edit().putString(PREF_UUID, uuid).apply() + fun applyTheme(context: Context) { val prefs = getPrefs(context) val mode = when (prefs.getString(PREF_THEME, "system")) { diff --git a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt index 91ccaec..6ede172 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt @@ -372,9 +372,8 @@ class SettingsActivity : AppCompatActivity() { private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) { showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER) - val prefs = PrefsHelper.getPrefs(this) - val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null) - val appKey = prefs.getString(PrefsHelper.PREF_KEY, null) + val appUuid = PrefsHelper.getDeviceCredentialUuid(this) + val appKey = PrefsHelper.getPsk(this) lifecycleScope.launch(Dispatchers.IO) { val result = readKeyFromEsp(usbManager, device, appUuid, appKey) @@ -384,10 +383,8 @@ class SettingsActivity : AppCompatActivity() { progressDialog = null when (result) { is KeyResult.Success -> { - prefs.edit() - .putString(PrefsHelper.PREF_UUID, result.deviceUuid) - .putString(PrefsHelper.PREF_KEY, result.hexKey) - .apply() + PrefsHelper.setDeviceCredentialUuid(this@SettingsActivity, result.deviceUuid) + PrefsHelper.setPsk(this@SettingsActivity, result.hexKey) if (PrefsHelper.getDeviceMode(this@SettingsActivity) == "server") { PrefsHelper.setServerUuid(this@SettingsActivity, result.deviceUuid) } else { @@ -838,8 +835,7 @@ class SettingsActivity : AppCompatActivity() { private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice, espUuid: String, espKey: String, appKey: String) { - val prefs = PrefsHelper.getPrefs(this) - val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null) + val appUuid = PrefsHelper.getDeviceCredentialUuid(this) val newAppKey = appKey.ifEmpty { generateRandomHexKey() } val newAppUuid = appUuid ?: generateRandomHex(UUID_BYTE_COUNT) @@ -946,11 +942,8 @@ class SettingsActivity : AppCompatActivity() { } } - val prefs = PrefsHelper.getPrefs(this) - prefs.edit() - .putString(PrefsHelper.PREF_UUID, newUuid) - .putString(PrefsHelper.PREF_KEY, appKey) - .apply() + PrefsHelper.setDeviceCredentialUuid(this, newUuid) + PrefsHelper.setPsk(this, appKey) if (PrefsHelper.getDeviceMode(this) == "server") { PrefsHelper.setServerUuid(this, newUuid) } else { diff --git a/docs/superpowers/plans/2026-08-04-code-review-fixes-v2.md b/docs/superpowers/plans/2026-08-04-code-review-fixes-v2.md new file mode 100644 index 0000000..b500431 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-code-review-fixes-v2.md @@ -0,0 +1,305 @@ +# Code Review Fixes (Batch 2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 6 issues found in code review: broken client OTA UI flow (SettingsActivity button), missing timeout in OTA polling, recomputeClientOta race condition, committed upload_port, wrong LED startup sequence, and noisy ESP_LOGI diagnostics. + +**Architecture:** All fixes are isolated single-file edits. No new files. No architecture changes. Version bumps: server 21→22, client 11→12. + +**Tech Stack:** C++ (Arduino/ESP-IDF), Kotlin (Android), PlatformIO + +--- + +### Task 1: Fix SettingsActivity button — client+BLE now routes to startClientOtaUpdate + +**Files:** +- Modify: `android/app/src/main/java/nl/eddydevink/sylda/SettingsActivity.kt:167-174` + +- [ ] **Step 1: Replace button click handler** + +In `setupButtons()` (or wherever the click listener is set), replace lines 167-174 with the corrected logic: + +```kotlin +findViewById