diff --git a/README.md b/README.md index e647f6b..741f8da 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,6 @@ module → App: SylDa_Server\n of SylDa_Client\n (device type) App → module: KEY?\n module → App: DEVICE_ID:\n (module UUID, 16 bytes = 32 hex) module → App: PSK:\n (PSK, 32 bytes = 64 hex) -module → App: SERVER_UUID:\n (alleen client: opgeslagen server UUID, 32 hex) module → App: NO_KEY\n (als module nog geen key heeft) App → module: SET_KEY:\n (nieuwe PSK schrijven, 64 hex) @@ -140,13 +139,22 @@ module → App: OK\n De server ondersteunt tot **2 gelijktijdige BLE-clients** (MAX_CLIENTS 2). Elke client krijgt een onafhankelijke ECDH-handshake, AES-sleutel en sequence counter. Relais-commando's van alle clients worden gecombineerd: een relais gaat aan als minimaal één client het aan-commando stuurt (OR-logica). +De server kan OTA-updates distribueren naar verbonden clients via het **broker protocol**: + +``` +BROKER_CMD_UPDATE_AVAILABLE (0x10) — server vertelt client dat er een update klaarstaat +BROKER_CMD_CLIENT_READY (0x20) — client meldt zich klaar voor OTA +``` + +Broker packets zijn versleuteld met AES-256-GCM (48 bytes: 4 seq + 8 nonce + 20 plain + 16 tag), onafhankelijk van relay-commando encryptie. + ### Beveiligingsanalyse | Aanval | Hoe geweerd | |---|---| | **Replay** | Sequence counter — zelfde packet werkt niet 2× (`seq <= lastSeq` geweerd) | | **Ongeautoriseerd device** | ECDH pubkey HMAC vereist PSK — verkeerde PSK = afgewezen public key | -| **BLE spoofing (client)** | Client gebruikt service UUID + manufacturer data voor scanning, full 16-byte UUID + ECDH PSK-HMAC voor verificatie na connect. 4-byte manufacturer data scan-filter (2^32 combinaties) kan tot onnodige connectiepogingen leiden — deze falen alsnog bij 16-byte UUID check of ECDH handshake. Voorheen (issue #10) werd alleen op naam "SylDa_Server" gescant zonder verdere verificatie. | +| **BLE spoofing (client)** | **Bekend lek (issue #10):** ESP32 client scant op naam "SylDa_Server" i.p.v. UUID — geen server-verificatie. De Android app scant wél op service UUID én verifieert device UUID na connect. | | **Commando vervalsing** | AES-GCM versleutelt en authenticeert command byte + nonce — wijzigen maakt tag ongeldig | | **Afluisteren** | AES-256-GCM biedt confidentiality — commando's zijn onleesbaar zonder AES-sleutel | | **Brute-force PSK** | 32-byte PSK = 2^256 mogelijkheden — onhaalbaar | @@ -169,14 +177,16 @@ De firmware kan **over-the-air** worden geüpdatet via BLE (zonder USB): Tijdens OTA zijn relais geforceerd UIT en knippert de LED groen. Na afloop (wachtend op confirm) is de LED geel. +> **Client OTA:** Clients kunnen ook OTA ontvangen. De server stuurt `BROKER_CMD_UPDATE_AVAILABLE`, de client antwoordt `BROKER_CMD_CLIENT_READY`, en start vervolgens een eigen BLE OTA-server (`SylDa_Client_OTA_XX`). De app verbindt met de client direct voor de OTA transfer. + ## Firmware De firmware is geschreven in C++ met PlatformIO en de Arduino-framework. BLE: ESP32 NimBLE library, crypto: mbedtls (ingebouwd in ESP-IDF). | Environment | Bronbestand | Rol | |---|---|---| -| `s3lite-server` | `src/main.cpp` | Ontvangt BLE-verbindingen, bedient relais | -| `s3lite-client` | `src/client.cpp` | Verbindt als BLE-central met server, stuurt relais-commando's | +| `s3lite-server` | `src/main.cpp` | Ontvangt BLE-verbindingen, bedient relais, broker voor OTA-distributie | +| `s3lite-client` | `src/client.cpp` | Verbindt als BLE-central met server, stuurt relais-commando's, ontvangt OTA | Firmwareversies: `version.txt` (single source of truth) → `src/version.h` (gegenereerd door `tools/pio_version_pre.py`) + `SylDaVersions.kt` (Android). Versiebeheer via `tools/check_versions.sh`. @@ -207,6 +217,8 @@ De touch sensoren, BLE, en interne knop werken onafhankelijk (OR-logica). De int | VERSION | `beb5483e-36e1-4688-b7f5-ea07361f26db` | READ | Firmware versie string | | OTA | `beb5483e-36e1-4688-b7f5-ea07361f26da` | WRITE_NR, NOTIFY | BLE OTA firmware update | | ECDH | `beb5483e-36e1-4688-b7f5-ea07361f26dc` | WRITE_NR, NOTIFY | ECDH sleuteluitwisseling | +| CLIENT_REGISTRY | `beb5483e-36e1-4688-b7f5-ea07361f26dd` | READ, WRITE_NR, NOTIFY | Client registratie (UUID + versie) | +| CLIENT_CONTROL | `beb5483e-36e1-4688-b7f5-ea07361f26de` | WRITE_NR, NOTIFY | Broker protocol (OTA notificaties) | ### Key provisioning @@ -249,7 +261,7 @@ Setup done. **Client firmware log (geprovisioneerd):** ``` -=== SylDa Client v5 === +=== AtomS3 Lite SylDa Client v6 === PSK loaded from NVS UUID loaded from NVS BLE: scanning... @@ -311,6 +323,7 @@ Firmware binaries: `tools/copy_firmware.sh` kopieert `.bin` bestanden naar `asse - **Key exchange geïntegreerd:** Module bijwerken = versie check → flashen (indien nodig) → PSK-uitwisseling — volledig automatisch - **Thema-selector:** Instellingen → Weergave met Systeem, Licht of Donker - **Permissies:** Vraagt BLUETOOTH_SCAN en BLUETOOTH_CONNECT. Bij weigeren wordt verwezen naar Instellingen +- **Broker protocol:** Ontvangt `CLIENT_CONTROL` notificaties van de server (OTA beschikbaar, client ready) ### Instellingenscherm @@ -333,7 +346,7 @@ De PSK en UUIDs worden opgeslagen in SharedPreferences en overleven app-herstart Vereisten: - Android SDK (API 34+) -- Java 17 target (JDK 21 voor Gradle — Java 26 werkt niet) +- Java 21 (Java 26 werkt niet met Gradle) - Gradle 8.14 (via wrapper) - AGP 8.5.2, Kotlin 2.0.21 (K2 compiler) - USB host support (raw Android USB API, geen externe serial library) @@ -388,8 +401,8 @@ Project_SylDa/ ├── version.txt # Single source of truth voor firmwareversies ├── icon.png / icon.svg # Project icon ├── src/ -│ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA) -│ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais) +│ ├── main.cpp # Server firmware (BLE + ECDH/AES + NVS + USB + touch + relais + OTA + broker) +│ ├── client.cpp # Client firmware (BLE central + ECDH/AES + touch + relais + OTA server mode) │ ├── ecdh_helper.h # ECDH crypto helper (secp256r1, AES-sleutel afleiding, pubkey HMAC) │ └── version.h # Auto-gegenereerd uit version.txt — niet handmatig bewerken ├── tools/ @@ -479,10 +492,11 @@ HMAC-SHA256 (vroegere versies) bood authenticatie maar geen confidentiality. ECD - De app gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) — geen externe serial library. - `minSdk` is 26 (Android 8.0). `compileSdk`/`targetSdk` blijft op 34. - Sequence counters starten bij 1 (niet 0), wrappen bij 1.000.000. -- BLE MTU: server stelt 248 in (ESP32 NimBLE limiet), app vraagt 512. OTA/ECDH chunks passen zich aan aan `negotiatedMtu - 7`. +- BLE MTU: server stelt 248 in (ESP32 NimBLE limiet), app vraagt 512. OTA/ECDH chunks passen zich aan aan `negotiatedMtu - 3`. - Server krijgt maximaal 2 gelijktijdige BLE-clients. Client slaat server BLE-adres op in NVS voor snelle reconnect (valt terug op scan als adres niet werkt). - Server advertising bevat manufacturer data (0xFFFF) met eerste 4 bytes van device UUID voor snelle identificatie. - OTA: 500-byte chunks (aangepast aan MTU), 3 retries per chunk, SHA-256 hash verificatie, 10s 0xFF confirm timeout. +- Client in OTA server modus advertiseert als `SylDa_Client_OTA_XX` (XX = laatste 2 hex van device UUID). ## Release 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..1f35149 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleRelayManager.kt @@ -47,7 +47,13 @@ class BleRelayManager( 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 CLIENT_REGISTRY_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26dd") + private val CLIENT_CONTROL_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26de") private val OTA_CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val BROKER_PLAIN_SIZE = 20 + private const val BROKER_PKT_SIZE = 4 + 8 + BROKER_PLAIN_SIZE + 16 + private const val BROKER_CMD_UPDATE_AVAILABLE: Byte = 0x10 + private const val BROKER_CMD_CLIENT_READY: Byte = 0x20 private const val SCAN_TIMEOUT_MS = 30000L private const val RECONNECT_DELAY_MS = 2000L @@ -83,6 +89,13 @@ class BleRelayManager( @Volatile private var ecdhDeferred: CompletableDeferred? = null private var ecdhNotifyBuf: ByteArray = ByteArray(0) private var ecdhCccdPending = false + private var registryCharacteristic: BluetoothGattCharacteristic? = null + private var controlCharacteristic: BluetoothGattCharacteristic? = null + private var brokerNotifyBuf: ByteArray = ByteArray(0) + private var brokerDeferred: CompletableDeferred? = null + private var clientUuid: ByteArray? = null + private var clientVersion: Int = 0 + private var clientStatus: Int = 0 interface OtaNotifyCallback { fun onOtaNotify(status: Int, seq: Int) @@ -279,6 +292,95 @@ class BleRelayManager( fun getNegotiatedMtu(): Int = negotiatedMtu + fun getClientUuid(): ByteArray? = clientUuid + fun getClientVersion(): Int = clientVersion + fun getClientStatus(): Int = clientStatus + + @SuppressLint("MissingPermission") + fun sendUpdateAvailable(targetUuid: ByteArray): Boolean { + val gatt = bluetoothGatt ?: return false + val key = aesKey ?: return false + val ctrlChar = controlCharacteristic ?: return false + + val plainPayload = ByteArray(BROKER_PLAIN_SIZE) + plainPayload[0] = BROKER_CMD_UPDATE_AVAILABLE + System.arraycopy(targetUuid, 0, plainPayload, 1, targetUuid.size) + + val pkt = buildBrokerPacket(plainPayload, key) + + ctrlChar.value = pkt + ctrlChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + return gatt.writeCharacteristic(ctrlChar) + } + + @SuppressLint("MissingPermission") + fun subscribeToBrokerNotifications(): Boolean { + val gatt = bluetoothGatt ?: return false + val ctrlChar = controlCharacteristic ?: return false + + if (!gatt.setCharacteristicNotification(ctrlChar, true)) { + Log.e(TAG, "Broker: setCharacteristicNotification returned false") + return false + } + val desc = ctrlChar.getDescriptor(OTA_CCCD_UUID) + if (desc != null) { + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + if (!gatt.writeDescriptor(desc)) { + Log.w(TAG, "Broker: writeDescriptor for CCCD returned false") + } + } + return true + } + + private fun buildBrokerPacket(plainPayload: ByteArray, key: ByteArray): ByteArray { + val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array() + val nonceRandom = ByteArray(8) + SecureRandom().nextBytes(nonceRandom) + + val nonce = ByteArray(GCM_NONCE_LEN) + System.arraycopy(seqBytes, 0, nonce, 0, 4) + System.arraycopy(nonceRandom, 0, nonce, 4, 8) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(GCM_TAG_LEN * 8, nonce)) + + val cipherOutput = cipher.doFinal(plainPayload) + + val packet = ByteArray(BROKER_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 + } + + private fun decryptBrokerPacket(data: ByteArray, key: ByteArray): ByteArray? { + if (data.size != BROKER_PKT_SIZE) return null + + val nonce = ByteArray(GCM_NONCE_LEN) + System.arraycopy(data, 0, nonce, 0, 4) + System.arraycopy(data, 4, nonce, 4, 8) + + val ciphertext = ByteArray(BROKER_PLAIN_SIZE) + System.arraycopy(data, 12, ciphertext, 0, BROKER_PLAIN_SIZE) + + val tag = ByteArray(GCM_TAG_LEN) + System.arraycopy(data, 12 + BROKER_PLAIN_SIZE, tag, 0, GCM_TAG_LEN) + + return try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val gcmSpec = GCMParameterSpec(GCM_TAG_LEN * 8, nonce) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), gcmSpec) + cipher.update(ciphertext) + val result = cipher.doFinal(tag) + result + } catch (e: Exception) { + Log.e(TAG, "Broker: decrypt failed", e) + null + } + } + @SuppressLint("MissingPermission") private fun connectToDevice(device: android.bluetooth.BluetoothDevice) { handler.post { callbacks.onStatusChanged(BleStatus.Connecting) } @@ -336,11 +438,16 @@ class BleRelayManager( relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) otaCharacteristic = service.getCharacteristic(OTA_UUID) ecdhCharacteristic = service.getCharacteristic(ECDH_UUID) + registryCharacteristic = service.getCharacteristic(CLIENT_REGISTRY_UUID) + controlCharacteristic = service.getCharacteristic(CLIENT_CONTROL_UUID) val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) val versionChar = service.getCharacteristic(VERSION_UUID) if (relayCharacteristic != null && uuidChar != null) { Log.d(TAG, "Reading device UUID characteristic...") gatt.readCharacteristic(uuidChar) + if (controlCharacteristic != null) { + subscribeToBrokerNotifications() + } } else { Log.e(TAG, "Service or characteristics not found") handler.post { callbacks.onStatusChanged(BleStatus.ServiceNotFound) } @@ -407,6 +514,23 @@ class BleRelayManager( ecdhNotifyBuf = value.copyOf() ecdhDeferred?.complete(true) Log.d(TAG, "ECDH notify received (${value.size} bytes)") + } else if (characteristic.uuid == CLIENT_CONTROL_UUID) { + Log.d(TAG, "Broker: control notification received (${value.size} bytes)") + val key = aesKey + if (key != null) { + val plainPayload = decryptBrokerPacket(value, key) + if (plainPayload != null && plainPayload.size >= BROKER_PLAIN_SIZE) { + val cmd = plainPayload[0].toInt() and 0xFF + Log.d(TAG, "Broker: decrypted cmd=0x${cmd.toString(16)}") + if (cmd == BROKER_CMD_CLIENT_READY.toInt()) { + val uuid = plainPayload.copyOfRange(1, 1 + 16) + clientUuid = uuid + clientStatus = 1 + Log.d(TAG, "Broker: client ready for OTA, uuid=${uuid.joinToString("") { "%02X".format(it) }}") + handler.post { callbacks.onClientReady(uuid) } + } + } + } } } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt b/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt index ee44abd..5d3dd95 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/BleStatus.kt @@ -19,4 +19,5 @@ enum class BleStatus { interface BleCallbacks { fun onStatusChanged(status: BleStatus) fun onFirmwareVersion(espVersion: String, appVersion: String) + fun onClientReady(uuid: ByteArray) } diff --git a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt index e18839c..f1181e7 100644 --- a/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt +++ b/android/app/src/main/java/nl/eddydevink/sylda/MainActivity.kt @@ -226,4 +226,14 @@ class MainActivity : AppCompatActivity(), BleCallbacks { } } } + + override fun onClientReady(uuid: ByteArray) { + runOnUiThread { + if (isFinishing || isDestroyed || !lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return@runOnUiThread + Toast.makeText(this, "Client ready for OTA update", Toast.LENGTH_SHORT).show() + val intent = Intent(this, SettingsActivity::class.java) + intent.putExtra("client_ota_uuid", uuid) + settingsLauncher.launch(intent) + } + } } diff --git a/docs/superpowers/specs/2026-08-04-client-ble-ota-spec.md b/docs/superpowers/specs/2026-08-04-client-ble-ota-spec.md new file mode 100644 index 0000000..219c097 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-client-ble-ota-spec.md @@ -0,0 +1,86 @@ +# Spec: Client BLE OTA via Broker Server + +**Status:** Draft +**Datum:** 2026-08-04 +**Auteur:** opencore (AI Agent) + +## 1. Introductie +Dit document beschrijft de architectuur en het protocol voor het uitvoeren van een Firmware Over-The-Air (OTA) update op de SylDa_Client (ESP32) via de SylDa_Server (ESP32) als broker. + +## 2. Architectuur +Het systeem bestaat uit drie componenten: +1. **Android App:** Initiator van de update, bevat de nieuwe firmware binary. +2. **SylDa_Server (Central/Server):** Fungeert als informatiepunt (broker) tussen App en Client. +3. **SylDa_Client (Central/Client):** Ontvanger van de update. Gaat in een "Dual-Role" (Central + Server) tijdens de update procedure. + +## 3. BLE Interface (SylDa_Server) +De Server breidt zijn bestaande service (`4fafc201-1fb5-459e-8fcc-c5c9c331914b`) uit met de volgende characteristics: + +### 3.1 CLIENT_REGISTRY (`beb5483e-36e1-4688-b7f5-ea07361f26dd`) +* **Eigenschappen:** Read, Notify +* **Beveiliging:** AES/GCM encrypted +* **Payload (37 bytes totaal):** + * `[0:3]` Seq (4 bytes) + * `[4:11]` Nonce Random (8 bytes) + * `[12]` Ciphertext: `[UUID (16b) | Version (2b) | Status (1b)]` + * `[13:28]` Auth Tag (16 bytes) +* **Status codes:** + * `0x00`: Normaal / Idle + * `0x01`: Ready for OTA (Advertising) + * `0x02`: Updating (Transfer bezig) + +### 3.2 CLIENT_CONTROL (`beb5483e-36e1-4688-b7f5-ea07361f26de`) +* **Eigenschappen:** Write, Notify +* **Beveiliging:** AES/GCM encrypted +* **Payload (37 bytes totaal):** + * `[0:3]` Seq (4 bytes) + * `[4:11]` Nonce Random (8 bytes) + * `[12]` Ciphertext: `[TargetUUID (16b) | Action (1b)]` + * `[13:28]` Auth Tag (16 bytes) +* **Action codes:** + * `0x10`: `NOTIFY_UPDATE_AVAILABLE` (Server -> Client) + * `0x20`: `CLIENT_READY_FOR_OTA` (Client -> Server -> App) + +## 4. BLE Interface (SylDa_Client OTA Mode) +Wanneer de Client in OTA-modus gaat, start hij een eigen BLE Server met de volgende gegevens: +* **Device Name:** `SylDa_Client_OTA_[Last-4-chars-UUID]` +* **Service UUID:** `4fafc201-1fb5-459e-8fcc-c5c9c331914b` (Gelijk aan Server) +* **OTA Characteristic:** `beb5483e-36e1-4688-b7f5-ea07361f26da` (Gelijk aan Server) +* **UUID Characteristic:** `beb5483e-36e1-4688-b7f5-ea07361f26d9` (Bevat eigen UUID ter verificatie) + +## 5. Sequence Diagram + +1. **Bootstrapping:** + * Client verbindt met Server. + * Client schrijft zijn UUID en Versie naar `CLIENT_REGISTRY` op de Server. +2. **Update Detectie:** + * App verbindt met Server. + * App leest `CLIENT_REGISTRY` en ziet dat Client v5 draait (App heeft v6). +3. **Update Trigger:** + * App stuurt `0x10` (Update Available) naar `CLIENT_CONTROL` (Target: Client UUID). + * Server stuurt Notify naar Client. +4. **Client Voorbereiding:** + * Client ontvangt notificatie. + * Client start `NimBLEServer` en begint te adverteren. + * Client schrijft `0x20` (Ready) naar `CLIENT_CONTROL` op de Server. +5. **App Verbinding naar Client:** + * App ontvangt `0x20` notificatie van de Server. + * App (na gebruikersbevestiging) scant naar `SylDa_Client_OTA` met de juiste UUID. + * App verbindt direct met de Client. +6. **OTA Transfer:** + * App voert de standaard OTA-procedure uit (Start command, chunks, SHA256 verify, End confirm). + * Client verwerkt de OTA en voert zijn relais-taken uit zolang de transfer niet is gestart. +7. **Finalisatie:** + * Client herstart na succesvolle OTA. + * Client verbindt opnieuw met Server en rapporteert de nieuwe versie. + +## 6. Beveiliging en Foutafhandeling +* **Encryptie:** Alle "Broker" communicatie op de Server verloopt via de bestaande AES/GCM laag met de gedeelde PSK. +* **UUID Pinning:** De App MOET de UUID van de Client verifiëren via de `UUID_CHAR_UUID` voordat de OTA start. +* **Timeouts:** De Client stopt de OTA-modus (Server-rol) na een succesvolle update of een handmatige reset. (Gebruiker behoudt controle via de App). +* **Relais:** De Client blijft operationeel tot het moment dat de OTA-transfer daadwerkelijk begint. + +## 7. Rollen en Verantwoordelijkheden +* **Server:** Passief doorgeefluik van status en notificaties. Slaat geen firmware op. +* **Client:** Verantwoordelijk voor het veilig flashen van de eigen firmware en het rapporteren van de status. +* **App:** Verantwoordelijk voor het orkestreren van het proces en het leveren van de firmware. diff --git a/src/client.cpp b/src/client.cpp index 44a08e6..ac84754 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -6,11 +6,12 @@ #include #include #include +#include +#include #include #include "version.h" #include "ecdh_helper.h" -#include "serial_utils.h" static const char *TAG = "client"; @@ -38,7 +39,15 @@ CRGB leds[NUM_LEDS]; #define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8" #define UUID_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d9" #define ECDH_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dc" +#define CLIENT_REGISTRY_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dd" +#define CLIENT_CONTROL_UUID "beb5483e-36e1-4688-b7f5-ea07361f26de" +#define VERSION_UUID "beb5483e-36e1-4688-b7f5-ea07361f26db" +#define OTA_UUID "beb5483e-36e1-4688-b7f5-ea07361f26da" #define ESP_UUID_LEN 16 +#define BROKER_PLAIN_SIZE 20 +#define BROKER_PKT_SIZE (4 + 8 + BROKER_PLAIN_SIZE + 16) +#define BROKER_CMD_UPDATE_AVAILABLE 0x10 +#define BROKER_CMD_CLIENT_READY 0x20 #define NVS_NAMESPACE "syl" #define NVS_PSK_NAME "psk" #define NVS_UUID_NAME "uuid" @@ -76,6 +85,30 @@ uint32_t connectionStableMs = 0; uint32_t relayFlashUntil = 0; bool otaInProgress = false; bool otaEnded = false; +bool otaServerMode = false; + +static NimBLEServer *pOtaServer = nullptr; +static NimBLECharacteristic *pOtaChar = nullptr; +static NimBLECharacteristic *pOtaUuidChar = nullptr; + +esp_ota_handle_t otaHandle = 0; +const esp_partition_t *otaPartition = nullptr; +uint32_t otaExpectedSize = 0; +uint32_t otaWritten = 0; +uint8_t otaExpectedSha[32]; +mbedtls_sha256_context otaShaCtx; +bool otaShaStarted = false; +bool otaReady = false; +bool otaConfirmed = false; +bool otaEarlyConfirm = false; +uint32_t otaEndedAt = 0; +#define OTA_CONFIRM_TIMEOUT_MS 10000 + +#define OTA_CMD_START 0x10 +#define OTA_STATUS_ACK 0x00 +#define OTA_STATUS_NAK 0x01 +#define OTA_STATUS_COMPLETE 0x10 +#define OTA_STATUS_ERROR 0x11 static bool bleReady = false; static bool serverFound = false; @@ -127,6 +160,20 @@ class MyClientCallbacks : public NimBLEClientCallbacks { static MyClientCallbacks clientCallbacks; +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; + } + return true; +} + +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 buildAesPacket(uint8_t cmd, uint8_t *packet) { uint32_t s = ++seq; packet[0] = (s >> 24) & 0xFF; @@ -173,6 +220,182 @@ static void disconnectClient() { memset(aesKey, 0, AES_KEY_LEN); } +static void sendOtaNotify(uint8_t status, uint32_t seq) { + if (pOtaChar == nullptr) return; + uint8_t buf[5]; + buf[0] = status; + buf[1] = (seq >> 24) & 0xFF; + buf[2] = (seq >> 16) & 0xFF; + buf[3] = (seq >> 8) & 0xFF; + buf[4] = seq & 0xFF; + pOtaChar->setValue(buf, 5); + bool ok = pOtaChar->notify(); + if (!ok) { + ESP_LOGW(TAG, "OTA: notify() failed (status=0x%02X seq=%lu)", status, seq); + } +} + +static void otaReset() { + otaInProgress = false; + otaHandle = 0; + otaPartition = nullptr; + otaExpectedSize = 0; + otaWritten = 0; + otaReady = false; + otaConfirmed = false; + otaEarlyConfirm = false; + if (otaShaStarted) { + mbedtls_sha256_free(&otaShaCtx); + otaShaStarted = false; + } +} + +static void abortOta(const char *reason) { + ESP_LOGE(TAG, "OTA: abort — %s", reason); + if (otaHandle != 0) { + esp_ota_end(otaHandle); + } + otaReset(); + sendOtaNotify(OTA_STATUS_ERROR, 0); +} + +class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override { + std::string value = pChar->getValue(); + if (value.empty()) return; + const uint8_t *data = (const uint8_t *)value.data(); + size_t len = value.length(); + + if (len == 5 && data[0] == OTA_CMD_START) { + if (otaInProgress) { + esp_ota_end(otaHandle); + otaReset(); + } + otaExpectedSize = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | + ((uint32_t)data[3] << 8) | (uint32_t)data[4]; + otaPartition = esp_ota_get_next_update_partition(nullptr); + if (otaPartition == nullptr) { + ESP_LOGE(TAG, "OTA: no update partition found"); + sendOtaNotify(OTA_STATUS_ERROR, 0); + return; + } + esp_err_t err = esp_ota_begin(otaPartition, otaExpectedSize, &otaHandle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTA: begin failed (%d)", err); + sendOtaNotify(OTA_STATUS_ERROR, 0); + return; + } + otaInProgress = true; + otaWritten = 0; + mbedtls_sha256_init(&otaShaCtx); + mbedtls_sha256_starts_ret(&otaShaCtx, 0); + otaShaStarted = true; + ESP_LOGI(TAG, "OTA: started, expecting %lu bytes", otaExpectedSize); + return; + } + + if (len == 32 && otaInProgress) { + memcpy(otaExpectedSha, data, 32); + uint8_t computedSha[32]; + mbedtls_sha256_finish_ret(&otaShaCtx, computedSha); + mbedtls_sha256_free(&otaShaCtx); + otaShaStarted = false; + + ESP_LOGD(TAG, "OTA: SHA256 verify (written=%lu expected=%lu)", otaWritten, otaExpectedSize); + if (memcmp(computedSha, otaExpectedSha, 32) != 0) { + ESP_LOGE(TAG, "OTA: SHA256 mismatch — aborting"); + esp_ota_abort(otaHandle); + otaReset(); + sendOtaNotify(OTA_STATUS_ERROR, 0); + return; + } + + ESP_LOGI(TAG, "OTA: SHA256 OK, waiting for 0xFF confirm"); + otaReady = true; + sendOtaNotify(OTA_STATUS_COMPLETE, 0); + return; + } + + if (!otaInProgress) { + ESP_LOGD(TAG, "OTA: write ignored (not in progress, len=%d data0=0x%02X)", len, data[0]); + return; + } + + if (len == 1 && data[0] == 0xFF) { + ESP_LOGD(TAG, "OTA: 0xFF received (ready=%d confirmed=%d inProgress=%d)", + otaReady, otaConfirmed, otaInProgress); + if (otaReady) { + ESP_LOGI(TAG, "OTA: 0xFF confirm accepted"); + otaConfirmed = true; + } else { + ESP_LOGD(TAG, "OTA: 0xFF arrived early, deferring"); + otaEarlyConfirm = true; + } + return; + } + + if (data[0] < 0x10) { + uint32_t seq = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | (uint32_t)data[3]; + size_t dataLen = len - 4; + mbedtls_sha256_update_ret(&otaShaCtx, data + 4, dataLen); + esp_err_t err = esp_ota_write(otaHandle, data + 4, dataLen); + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTA: write error at seq %lu (%d)", seq, err); + sendOtaNotify(OTA_STATUS_NAK, seq); + abortOta("write error"); + return; + } + otaWritten += dataLen; + sendOtaNotify(OTA_STATUS_ACK, seq); + } + } +}; + +static bool startOtaServer() { + if (otaServerMode) return true; + + char otaName[32]; + snprintf(otaName, sizeof(otaName), "SylDa_Client_OTA_%02X%02X", deviceUuid[0], deviceUuid[1]); + + NimBLEDevice::init(otaName); + NimBLEDevice::setMTU(248); + + pOtaServer = NimBLEDevice::createServer(); + + NimBLEService *pOtaService = pOtaServer->createService(SERVICE_UUID); + + pOtaChar = pOtaService->createCharacteristic( + OTA_UUID, + NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY + ); + pOtaChar->setCallbacks(new OtaCharacteristicCallbacks()); + + pOtaUuidChar = pOtaService->createCharacteristic( + UUID_CHAR_UUID, + NIMBLE_PROPERTY::READ + ); + pOtaUuidChar->setValue(deviceUuid, ESP_UUID_LEN); + + NimBLECharacteristic *pVerChar = pOtaService->createCharacteristic( + VERSION_UUID, + NIMBLE_PROPERTY::READ + ); + pVerChar->setValue(FIRMWARE_VERSION_CLIENT); + + pOtaService->start(); + + NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); + pAdvertising->setName(otaName); + pAdvertising->setMinInterval(32); + pAdvertising->setMaxInterval(64); + pAdvertising->start(); + + otaServerMode = true; + ESP_LOGI(TAG, "OTA: server started as %s", otaName); + return true; +} + static bool performEcdhHandshake(NimBLERemoteService *pSvc) { if (!pskProvisioned) { ESP_LOGE(TAG, "ECDH: no PSK"); @@ -308,6 +531,137 @@ static bool connectToServer() { relay1SendState = false; relay2SendState = false; ESP_LOGI(TAG, "BLE: server UUID verified, ECDH done, ready"); + + NimBLERemoteCharacteristic *pRegChar = pSvc->getCharacteristic(CLIENT_REGISTRY_UUID); + if (pRegChar == nullptr) { + ESP_LOGW(TAG, "BLE: CLIENT_REGISTRY characteristic not found"); + } else { + uint8_t plainPayload[BROKER_PLAIN_SIZE]; + memset(plainPayload, 0, BROKER_PLAIN_SIZE); + plainPayload[0] = 0x00; + memcpy(plainPayload + 1, deviceUuid, ESP_UUID_LEN); + uint16_t clientVersion = (uint16_t)atoi(FIRMWARE_VERSION_CLIENT); + plainPayload[1 + ESP_UUID_LEN] = (clientVersion >> 8) & 0xFF; + plainPayload[1 + ESP_UUID_LEN + 1] = clientVersion & 0xFF; + + uint8_t pkt[BROKER_PKT_SIZE]; + memset(pkt, 0, BROKER_PKT_SIZE); + uint32_t s = ++seq; + pkt[0] = (s >> 24) & 0xFF; + pkt[1] = (s >> 16) & 0xFF; + pkt[2] = (s >> 8) & 0xFF; + pkt[3] = s & 0xFF; + + for (int i = 0; i < 8; i++) pkt[4 + i] = (uint8_t)(esp_random() & 0xFF); + + uint8_t nonce[12]; + memcpy(nonce, pkt, 4); + memcpy(nonce + 4, pkt + 4, 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, "REGISTRY: setkey failed (ret=%d)", ret); + mbedtls_gcm_free(&gcm); + } else { + mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, + BROKER_PLAIN_SIZE, nonce, 12, NULL, 0, + plainPayload, pkt + 12, 16, pkt + 12 + BROKER_PLAIN_SIZE); + mbedtls_gcm_free(&gcm); + + if (pRegChar->writeValue(pkt, BROKER_PKT_SIZE, false)) { + ESP_LOGI(TAG, "REGISTRY: reported uuid + version %d to server", FIRMWARE_VERSION_CLIENT); + } else { + ESP_LOGW(TAG, "REGISTRY: write failed"); + } + } + } + + NimBLERemoteCharacteristic *pControlChar = pSvc->getCharacteristic(CLIENT_CONTROL_UUID); + if (pControlChar == nullptr) { + ESP_LOGW(TAG, "BLE: CLIENT_CONTROL characteristic not found"); + } else { + if (!pControlChar->subscribe(true, [pControlChar](NimBLERemoteCharacteristic *pChar, const uint8_t *data, size_t len, bool isNotify) { + if (len != BROKER_PKT_SIZE) { + ESP_LOGW(TAG, "BROKER: bad notify length %d", (int)len); + return; + } + + uint8_t nonce[12]; + memcpy(nonce, data, 4); + memcpy(nonce + 4, data + 4, 8); + + uint8_t plainPayload[BROKER_PLAIN_SIZE]; + uint8_t ciphertext[BROKER_PLAIN_SIZE]; + memcpy(ciphertext, data + 12, BROKER_PLAIN_SIZE); + + 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, "BROKER: setkey failed (ret=%d)", ret); + mbedtls_gcm_free(&gcm); + return; + } + + ret = mbedtls_gcm_auth_decrypt(&gcm, + BROKER_PLAIN_SIZE, nonce, 12, NULL, 0, + data + 12 + BROKER_PLAIN_SIZE, 16, ciphertext, plainPayload); + mbedtls_gcm_free(&gcm); + + if (ret != 0) { + ESP_LOGE(TAG, "BROKER: AES-GCM decrypt failed (ret=%d)", ret); + return; + } + + uint8_t cmd = plainPayload[0]; + ESP_LOGI(TAG, "BROKER: received cmd 0x%02X", cmd); + + if (cmd == BROKER_CMD_UPDATE_AVAILABLE) { + ESP_LOGI(TAG, "BROKER: update available, starting OTA server"); + startOtaServer(); + + uint8_t clientUuid[ESP_UUID_LEN]; + memcpy(clientUuid, plainPayload + 1, ESP_UUID_LEN); + + uint8_t plainResp[BROKER_PLAIN_SIZE]; + memset(plainResp, 0, BROKER_PLAIN_SIZE); + plainResp[0] = BROKER_CMD_CLIENT_READY; + memcpy(plainResp + 1, clientUuid, ESP_UUID_LEN); + + uint8_t pkt[BROKER_PKT_SIZE]; + memset(pkt, 0, BROKER_PKT_SIZE); + uint32_t s = ++seq; + pkt[0] = (s >> 24) & 0xFF; + pkt[1] = (s >> 16) & 0xFF; + pkt[2] = (s >> 8) & 0xFF; + pkt[3] = s & 0xFF; + + for (int i = 0; i < 8; i++) pkt[4 + i] = (uint8_t)(esp_random() & 0xFF); + + uint8_t nonceResp[12]; + memcpy(nonceResp, pkt, 4); + memcpy(nonceResp + 4, pkt + 4, 8); + + mbedtls_gcm_context gcmResp; + mbedtls_gcm_init(&gcmResp); + ret = mbedtls_gcm_setkey(&gcmResp, MBEDTLS_CIPHER_ID_AES, aesKey, AES_KEY_LEN * 8); + if (ret == 0) { + mbedtls_gcm_crypt_and_tag(&gcmResp, MBEDTLS_GCM_ENCRYPT, + BROKER_PLAIN_SIZE, nonceResp, 12, NULL, 0, + plainResp, pkt + 12, 16, pkt + 12 + BROKER_PLAIN_SIZE); + mbedtls_gcm_free(&gcmResp); + + pControlChar->writeValue(pkt, BROKER_PKT_SIZE, false); + ESP_LOGI(TAG, "BROKER: sent CLIENT_READY to server"); + } + } + }, true)) { + ESP_LOGW(TAG, "BROKER: subscribe to CLIENT_CONTROL failed"); + } + } + return true; } @@ -517,9 +871,46 @@ void loop() { bool relay1State = !t1Active || btnActive; bool relay2State = !t2Active; + if (otaServerMode && otaReady) { + esp_err_t err = esp_ota_end(otaHandle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTA: esp_ota_end failed (%d) — aborting", err); + otaReset(); + } else { + ESP_LOGI(TAG, "OTA: esp_ota_end OK, waiting for 0xFF confirm"); + otaEnded = true; + otaEndedAt = millis(); + } + otaReady = false; + } + + if (otaServerMode && otaEnded && otaEarlyConfirm) { + ESP_LOGD(TAG, "OTA: processing deferred 0xFF confirm"); + otaConfirmed = true; + otaEarlyConfirm = false; + } + + if (otaServerMode && otaEnded && !otaConfirmed && (millis() - otaEndedAt > OTA_CONFIRM_TIMEOUT_MS)) { + ESP_LOGW(TAG, "OTA: 0xFF confirm timeout (%lu ms) — discarding update", millis() - otaEndedAt); + otaReset(); + } + + if (otaServerMode && otaConfirmed) { + ESP_LOGI(TAG, "OTA: confirmed, setting boot partition"); + esp_err_t err = esp_ota_set_boot_partition(otaPartition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTA: set boot partition failed (%d)", err); + } else { + ESP_LOGI(TAG, "OTA: done, rebooting..."); + delay(500); + esp_restart(); + } + otaReset(); + } + CRGB ledColor; - if (otaInProgress) { + if (otaServerMode || otaInProgress) { ledColor = otaEnded ? CRGB::Yellow : (((millis() / 100) % 2) ? CRGB::Green : CRGB::Black); diff --git a/src/main.cpp b/src/main.cpp index 990d880..a6f3bcf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,7 +13,6 @@ #include #include "version.h" #include "ecdh_helper.h" -#include "serial_utils.h" static const char *TAG = "server"; @@ -47,6 +46,13 @@ CRGB leds[NUM_LEDS]; #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 CLIENT_REGISTRY_UUID "beb5483e-36e1-4688-b7f5-ea07361f26dd" +#define CLIENT_CONTROL_UUID "beb5483e-36e1-4688-b7f5-ea07361f26de" + +#define BROKER_CMD_UPDATE_AVAILABLE 0x10 +#define BROKER_CMD_CLIENT_READY 0x20 +#define BROKER_PLAIN_SIZE 20 +#define BROKER_PKT_SIZE (4 + 8 + BROKER_PLAIN_SIZE + 16) #define OTA_CHUNK_SIZE 500 #define OTA_CMD_START 0x10 @@ -81,6 +87,20 @@ struct ClientState { }; ClientState clients[MAX_CLIENTS]; +#define BROKER_STATUS_IDLE 0x00 +#define BROKER_STATUS_READY 0x01 +#define BROKER_STATUS_UPDATING 0x02 + +struct RemoteClientInfo { + uint8_t uuid[ESP_UUID_LEN]; + uint16_t version; + uint8_t status; + bool active; +}; +RemoteClientInfo remoteClients[MAX_CLIENTS]; +NimBLECharacteristic *pRegistryChar = nullptr; +NimBLECharacteristic *pControlChar = nullptr; + uint8_t psk[PSK_LEN]; uint8_t deviceUuid[ESP_UUID_LEN]; bool pskProvisioned = false; @@ -153,6 +173,10 @@ class MyServerCallbacks : public NimBLEServerCallbacks { clients[i].ecdhReady = false; memset(clients[i].aesKey, 0, AES_KEY_LEN); clients[i].ecdh.free(); + remoteClients[i].active = false; + remoteClients[i].version = 0; + remoteClients[i].status = BROKER_STATUS_IDLE; + memset(remoteClients[i].uuid, 0, ESP_UUID_LEN); connHandles[i] = 0xFFFF; connectedCount--; ESP_LOGI(TAG, "BLE: disconnected (slot=%d, clients=%d/%d, mac=%s)", @@ -212,6 +236,20 @@ static bool decryptAesGcm(const uint8_t *packet, int idx, uint8_t *outCmd) { return true; } +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; + } + return true; +} + +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 otaReset() { otaInProgress = false; otaHandle = 0; @@ -352,6 +390,193 @@ class OtaCharacteristicCallbacks : public NimBLECharacteristicCallbacks { } }; +static void sendBrokerNotify(int clientIdx, uint8_t cmd, const uint8_t *data, size_t dataLen) { + if (clientIdx < 0 || clientIdx >= MAX_CLIENTS || !connActive[clientIdx]) return; + + uint8_t plainPayload[BROKER_PLAIN_SIZE]; + memset(plainPayload, 0, BROKER_PLAIN_SIZE); + plainPayload[0] = cmd; + memcpy(plainPayload + 1, data, dataLen); + + uint8_t pkt[BROKER_PKT_SIZE]; + memset(pkt, 0, BROKER_PKT_SIZE); + uint32_t s = 0; + pkt[0] = (s >> 24) & 0xFF; + pkt[1] = (s >> 16) & 0xFF; + pkt[2] = (s >> 8) & 0xFF; + pkt[3] = s & 0xFF; + + for (int i = 0; i < 8; i++) pkt[4 + i] = (uint8_t)(esp_random() & 0xFF); + + uint8_t nonce[12]; + memcpy(nonce, pkt, 4); + memcpy(nonce + 4, pkt + 4, 8); + + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, clients[clientIdx].aesKey, AES_KEY_LEN * 8); + if (ret != 0) { + ESP_LOGE(TAG, "BROKER: send notify setkey failed"); + mbedtls_gcm_free(&gcm); + return; + } + + mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, + BROKER_PLAIN_SIZE, nonce, 12, NULL, 0, + plainPayload, pkt + 12, 16, pkt + 12 + BROKER_PLAIN_SIZE); + mbedtls_gcm_free(&gcm); + + pControlChar->setValue(pkt, BROKER_PKT_SIZE); + pControlChar->notify(connHandles[clientIdx], true); +} + +class RegistryCharacteristicCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override { + if (otaInProgress) return; + + std::string value = pChar->getValue(); + if (value.empty()) return; + const uint8_t *data = (const uint8_t *)value.data(); + size_t len = value.length(); + + if (len != BROKER_PKT_SIZE) { + ESP_LOGW(TAG, "REGISTRY: bad packet length %d (expected %d)", (int)len, BROKER_PKT_SIZE); + return; + } + + int idx = findClientIdx(connInfo.getConnHandle()); + if (idx < 0 || !clients[idx].ecdhReady) { + ESP_LOGW(TAG, "REGISTRY: write rejected — no ECDH (handle=%d)", connInfo.getConnHandle()); + return; + } + + uint8_t nonce[12]; + memcpy(nonce, data, 4); + memcpy(nonce + 4, data + 4, 8); + + uint8_t plainPayload[BROKER_PLAIN_SIZE]; + uint8_t ciphertext[BROKER_PLAIN_SIZE]; + memcpy(ciphertext, data + 12, BROKER_PLAIN_SIZE); + + 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, "REGISTRY: setkey failed (ret=%d)", ret); + mbedtls_gcm_free(&gcm); + return; + } + + ret = mbedtls_gcm_auth_decrypt(&gcm, + BROKER_PLAIN_SIZE, nonce, 12, NULL, 0, + data + 12 + BROKER_PLAIN_SIZE, 16, ciphertext, plainPayload); + mbedtls_gcm_free(&gcm); + + if (ret != 0) { + ESP_LOGE(TAG, "REGISTRY: AES-GCM decrypt failed (ret=%d)", ret); + return; + } + + uint8_t cmd = plainPayload[0]; + if (cmd != 0x00) { + ESP_LOGW(TAG, "REGISTRY: unexpected cmd 0x%02X (expected 0x00)", cmd); + return; + } + + memcpy(remoteClients[idx].uuid, plainPayload + 1, ESP_UUID_LEN); + remoteClients[idx].version = ((uint16_t)plainPayload[1 + ESP_UUID_LEN] << 8) | + (uint16_t)plainPayload[1 + ESP_UUID_LEN + 1]; + remoteClients[idx].status = BROKER_STATUS_IDLE; + remoteClients[idx].active = true; + + ESP_LOGI(TAG, "REGISTRY: client registered (slot=%d, uuid=%02X%02X%02X%02X, ver=%d)", + idx, + remoteClients[idx].uuid[0], remoteClients[idx].uuid[1], + remoteClients[idx].uuid[2], remoteClients[idx].uuid[3], + remoteClients[idx].version); + } +}; + +class ControlCharacteristicCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override { + if (otaInProgress) return; + + std::string value = pChar->getValue(); + if (value.empty()) return; + const uint8_t *data = (const uint8_t *)value.data(); + size_t len = value.length(); + + if (len != BROKER_PKT_SIZE) { + ESP_LOGW(TAG, "BROKER: bad packet length %d (expected %d)", (int)len, BROKER_PKT_SIZE); + return; + } + + int idx = findClientIdx(connInfo.getConnHandle()); + if (idx < 0 || !clients[idx].ecdhReady) { + ESP_LOGW(TAG, "BROKER: write rejected — no ECDH (handle=%d)", connInfo.getConnHandle()); + return; + } + + uint8_t nonce[12]; + memcpy(nonce, data, 4); + memcpy(nonce + 4, data + 4, 8); + + uint8_t plainPayload[BROKER_PLAIN_SIZE]; + uint8_t ciphertext[BROKER_PLAIN_SIZE]; + memcpy(ciphertext, data + 12, BROKER_PLAIN_SIZE); + + 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, "BROKER: setkey failed (ret=%d)", ret); + mbedtls_gcm_free(&gcm); + return; + } + + ret = mbedtls_gcm_auth_decrypt(&gcm, + BROKER_PLAIN_SIZE, nonce, 12, NULL, 0, + data + 12 + BROKER_PLAIN_SIZE, 16, ciphertext, plainPayload); + mbedtls_gcm_free(&gcm); + + if (ret != 0) { + ESP_LOGE(TAG, "BROKER: AES-GCM decrypt failed (ret=%d)", ret); + return; + } + + uint8_t cmd = plainPayload[0]; + ESP_LOGI(TAG, "BROKER: received cmd 0x%02X from slot %d", cmd, idx); + + if (cmd == BROKER_CMD_UPDATE_AVAILABLE) { + uint8_t targetUuid[ESP_UUID_LEN]; + memcpy(targetUuid, plainPayload + 1, ESP_UUID_LEN); + + for (int i = 0; i < MAX_CLIENTS; i++) { + if (remoteClients[i].active && + memcmp(remoteClients[i].uuid, targetUuid, ESP_UUID_LEN) == 0) { + ESP_LOGI(TAG, "BROKER: forwarding update notification to client slot %d", i); + sendBrokerNotify(i, BROKER_CMD_UPDATE_AVAILABLE, targetUuid, ESP_UUID_LEN); + break; + } + } + } else if (cmd == BROKER_CMD_CLIENT_READY) { + uint8_t clientUuid[ESP_UUID_LEN]; + memcpy(clientUuid, plainPayload + 1, ESP_UUID_LEN); + + ESP_LOGI(TAG, "BROKER: client ready for OTA"); + for (int i = 0; i < MAX_CLIENTS; i++) { + if (remoteClients[i].active && + memcmp(remoteClients[i].uuid, clientUuid, ESP_UUID_LEN) == 0) { + remoteClients[i].status = BROKER_STATUS_READY; + break; + } + } + } else { + ESP_LOGW(TAG, "BROKER: unknown cmd 0x%02X", cmd); + } + } +}; + class EcdhCharacteristicCallbacks : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override { if (otaInProgress) return; @@ -487,6 +712,10 @@ void setup() { for (int i = 0; i < MAX_CLIENTS; i++) { clients[i].ecdhReady = false; memset(clients[i].aesKey, 0, AES_KEY_LEN); + remoteClients[i].active = false; + remoteClients[i].version = 0; + remoteClients[i].status = BROKER_STATUS_IDLE; + memset(remoteClients[i].uuid, 0, ESP_UUID_LEN); } pinMode(TOUCH1_PIN, INPUT_PULLUP); @@ -536,7 +765,17 @@ void setup() { ); pOtaChar->setCallbacks(new OtaCharacteristicCallbacks()); - pService->start(); + pRegistryChar = pService->createCharacteristic( + CLIENT_REGISTRY_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY + ); + pRegistryChar->setCallbacks(new RegistryCharacteristicCallbacks()); + + pControlChar = pService->createCharacteristic( + CLIENT_CONTROL_UUID, + NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::NOTIFY + ); + pControlChar->setCallbacks(new ControlCharacteristicCallbacks()); NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising(); pAdvertising->setName("SylDa_Server"); diff --git a/src/serial_utils.h b/src/serial_utils.h deleted file mode 100644 index 6ca1137..0000000 --- a/src/serial_utils.h +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -#pragma once - -#include - -static inline 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; - } - return true; -} - -static inline 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); - } -}