Add BLE security: Just Works pairing + HMAC-SHA256 authentication

Two-layer security for the BLE relay:

1. BLE Just Works pairing (link-layer encryption)
   - ESP32 requires bonding before accepting writes
   - Characteristic permissions set to ESP_GATT_PERM_WRITE_ENCRYPTED
   - IO capability NO_INPUT_NO_OUTPUT (no passkey needed)
   - LE Secure Connections with bonding (ESP_LE_AUTH_REQ_SC_BOND)

2. HMAC-SHA256 authentication (application-level)
   - 32-byte shared secret in src/hmac_key.h (generated, gitignored)
   - Each command signed: HMAC-SHA256(key, command || sequence)
   - 37-byte packet format: [cmd:1][seq:4][hmac:32]
   - Sequence counter prevents replay (strictly increasing, starts at 1)
   - mbedtls on ESP, javax.crypto.Mac on Android

Key management:
- tools/generate_hmac_key.py generates random key + writes src/hmac_key.h
- ESP prints hex key to serial 5 seconds after boot
- Android app: SettingsActivity with eye-toggle password field
- Key stored in SharedPreferences, supports multiple ESP modules

Other changes:
- Android app: bonding flow (createBond -> BOND_BONDED -> discover)
- Android app: MTU negotiation (request 256)
- Android app: settings button (gear icon) in main screen
- Fix: sequence counter starts at 1 to prevent first-packet rejection
- Updated README with full security documentation
This commit is contained in:
eddy 2026-07-26 08:18:52 +02:00
commit 7f68e9ad70
13 changed files with 621 additions and 46 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
# HMAC key (generated, do not commit)
src/hmac_key.h
# PlatformIO # PlatformIO
.pio/ .pio/
.pioenvs/ .pioenvs/

170
README.md
View file

@ -2,6 +2,8 @@
BLE-gestuurde relais-switch voor de M5Stack Atom VoiceS3R (ESP32-S3) met een bijbehorende Android-app. Het relais kan worden bediend via een capacitieve touch-sensor (open-drain) óf via Bluetooth Low Energy vanaf een Android-telefoon. BLE-gestuurde relais-switch voor de M5Stack Atom VoiceS3R (ESP32-S3) met een bijbehorende Android-app. Het relais kan worden bediend via een capacitieve touch-sensor (open-drain) óf via Bluetooth Low Energy vanaf een Android-telefoon.
De BLE-verbinding is beveiligd met **twee lagen**: BLE Just Works pairing (link-layer encryptie) en **HMAC-SHA256 authenticatie** (application-level handtekening met replay-protectie).
## Hardware ## Hardware
| Component | Details | | Component | Details |
@ -35,9 +37,60 @@ Relais - → GND
> **Let op:** De ESP32-S3 interne pullup is ~45kΩ. Bij lange draden of elektrische ruis een externe 4.7kΩ–10kΩ pullup naar 3.3V plaatsen. > **Let op:** De ESP32-S3 interne pullup is ~45kΩ. Bij lange draden of elektrische ruis een externe 4.7kΩ–10kΩ pullup naar 3.3V plaatsen.
## Beveiliging
De relay is beveiligd tegen ongeautoriseerde toegang via twee onafhankelijke lagen:
### 1. BLE Just Works Pairing (link-layer encryptie)
- De ESP32 vereist **bonding** voordat de characteristic geschreven kan worden
- IO capability: `ESP_IO_CAP_NONE` (Just Works — geen passcode nodig)
- Authentication mode: `ESP_LE_AUTH_REQ_SC_BOND` (LE Secure Connections met bonding)
- De characteristic heeft `ESP_GATT_PERM_WRITE_ENCRYPTED` — writes worden alleen geaccepteerd over een versleutelde link
- Na de eerste keer paren onthoudt de telefoon de bonding; bij herverbinding wordt de link automatisch versleuteld
### 2. HMAC-SHA256 Authenticatie (application-level)
Omdat BLE Just Works geen MITM-bescherming biedt, is er een tweede laag toegevoegd:
- Both de ESP firmware en de Android app bevatten een **32-byte shared secret** (HMAC key)
- Elk commando wordt ondertekend met `HMAC-SHA256(key, command || sequence)`
- De ESP verifieert de handtekening voordat het relais schakelt
- Een **sequence counter** voorkomt replay-aanvallen: elk packet moet een strikt oplopend volgnummer hebben
#### Packet formaat (37 bytes)
```
Offset Grootte Veld Beschrijving
0 1 byte command 0x01 = relais aan, 0x00 = relais uit
1 4 bytes sequence Big-endian uint32, strikt oplopend (start bij 1)
5 32 bytes hmac HMAC-SHA256(key, command || sequence)
```
De sequence counter reset naar 0 bij elke (re)connectie op de ESP. De app start bij 1 om te zorgen dat het eerste packet altijd geaccepteerd wordt (`1 > 0`). Replay van een eerder packet wordt geweerd omdat `seq <= lastSeq`.
#### Key management
- De HMAC key staat in `src/hmac_key.h` (niet in git, `.gitignore`)
- Een CLI-tool genereert een willekeurige key: `python3 tools/generate_hmac_key.py`
- De ESP print de key 5 seconden na opstarten naar serial
- De gebruiker voert de key eenmalig in via het instellingenscherm in de Android app
- De app slaat de key op in SharedPreferences (overleeft app-herstarts)
- Zo kan dezelfde app gebruikt worden voor meerdere ESP modules — each met een eigen key
### Beveiligingsanalyse
| Aanval | Hoe geweerd |
|---|---|
| **Sniffen** | BLE link encrypted (AES-CCM via Just Works) |
| **Replay** | Sequence counter — zelfde packet werkt niet 2x |
| **Ongeautoriseerd device** | HMAC vereist geheime sleutel |
| **MITM** | Just Works is kwetsbaar voor MITM; HMAC maakt actieve MITM wel moeilijk (aanvaller moet de key kennen) |
| **Reverse engineering van key** | Sleutel staat in APK — extracteerbaar met reverse engineering. Voor hogere veiligheid: gebruiker ProGuard/R8 obfuscation. |
## Firmware ## Firmware
De firmware is geschreven in C++ met behulp van PlatformIO en de Arduino-framework. BLE wordt verzorgd door de ESP32 BLE library. De firmware is geschreven in C++ met behulp van PlatformIO en de Arduino-framework. BLE wordt verzorgd door de ESP32 BLE library, HMAC door mbedtls (ingebouwd in ESP-IDF).
### Logica ### Logica
@ -46,10 +99,10 @@ Het relais gaat aan als **één van beide** bronnen actief is:
| Bron | Voorwaarde | Resultaat | | Bron | Voorwaarde | Resultaat |
|---|---|---| |---|---|---|
| Touch sensor | G38 = LOW (aanraken) | Relais aan | | Touch sensor | G38 = LOW (aanraken) | Relais aan |
| BLE command | App stuurt `0x01` | Relais aan | | BLE command | App stuurt HMAC-ondertekend `0x01` | Relais aan |
| Geen van beide | G38 = HIGH + geen BLE command | Relais uit | | Geen van beide | G38 = HIGH + geen BLE command | Relais uit |
De touch sensor en BLE werken onafhankelijk. Als de sensor losgelaten wordt én de BLE-knop niet ingedrukt is, gaat het relais uit. De touch sensor en BLE werken onafzankelijk. Als de sensor losgelaten wordt en de BLE-knop niet ingedrukt is, gaat het relais uit.
### BLE GATT ### BLE GATT
@ -58,21 +111,41 @@ De touch sensor en BLE werken onafhankelijk. Als de sensor losgelaten wordt én
| Service | `4fafc201-1fb5-459e-8fcc-c5c9c331914b` | | Service | `4fafc201-1fb5-459e-8fcc-c5c9c331914b` |
| Characteristic | `beb5483e-36e1-4688-b7f5-ea07361f26d8` | | Characteristic | `beb5483e-36e1-4688-b7f5-ea07361f26d8` |
De characteristic ondersteunt `WRITE` en `NOTIFY`. Schrijf `0x01` om het relais aan te zetten, `0x00` om uit te zetten. De characteristic ondersteunt `WRITE` (encrypted) en `NOTIFY`. Writes vereisen een versleutelde link (bonding) plus HMAC-authenticatie.
### HMAC Key genereren
Voordat de firmware gecompileerd kan worden, moet er een HMAC key gegenereerd worden:
```bash
python3 tools/generate_hmac_key.py
```
Dit maakt `src/hmac_key.h` aan met een willekeurige 32-byte key en print de hex-representatie. Een bestaande key overschrijven:
```bash
echo "y" | python3 tools/generate_hmac_key.py
```
> **Belangrijk:** `src/hmac_key.h` staat in `.gitignore` en wordt niet gecommit. De key moet op de ESP en in de app identiek zijn.
### Serial monitor ### Serial monitor
Baudrate: **115200** (USB CDC). De firmware stuurt heartbeat-statussen elke 5 seconden en logt statuswijzigingen: Baudrate: **115200** (USB CDC). De firmware stuurt heartbeat-statussen elke 5 seconden, logt statuswijzigingen, en print de HMAC key 5 seconden na opstarten:
``` ```
=== Atom VoiceS3R BLE Relay === === Atom VoiceS3R BLE Relay ===
LED blink done, starting BLE... LED blink done, starting BLE...
BLE: advertising started as 'AtomS3R-Relay' BLE: advertising started as 'AtomS3R-Relay'
Printing HMAC key in 5 seconds...
HMAC Key: C3F17B612B93E82C56B055FA9B04E84664ACE1EC4E889F2763F271B65A5B54AF
Enter this key in the Android app > Settings.
Setup done. Setup done.
[heartbeat] relay=OFF touch=no ble=disconnected [heartbeat] relay=OFF touch=no ble=disconnected
BLE: device connected BLE: device connected
Relay ON (touch=YES, ble=connected) Relay ON (touch=YES, ble=connected)
Relay OFF (touch=no, ble=connected) BLE: relay ON (seq=1)
BLE: relay OFF (seq=2)
``` ```
### Uploaden ### Uploaden
@ -80,6 +153,7 @@ Relay OFF (touch=no, ble=connected)
> **Download-mode:** Houd de reset-knop ~2 seconden ingedrukt tot de groene LED aangaat, laat dan los. Het board is nu in download-mode. > **Download-mode:** Houd de reset-knop ~2 seconden ingedrukt tot de groene LED aangaat, laat dan los. Het board is nu in download-mode.
```bash ```bash
# Zorg dat src/hmac_key.h bestaat (zo niet: python3 tools/generate_hmac_key.py)
pio run --target upload pio run --target upload
``` ```
@ -92,18 +166,32 @@ Na het uploaden treedt er een harde reset op. Als het board in download-mode bli
- **Auto-connect:** De app scant automatisch bij opstarten naar het BLE-apparaat "AtomS3R-Relay" - **Auto-connect:** De app scant automatisch bij opstarten naar het BLE-apparaat "AtomS3R-Relay"
- **Auto-reconnect:** Bij wegvallen van de verbinding wordt na 2 seconden opnieuw gescant - **Auto-reconnect:** Bij wegvallen van de verbinding wordt na 2 seconden opnieuw gescant
- **Hold-to-activate:** Houd de knop ingedrukt voor relais aan, loslaten voor relais uit (zelfde gedrag als de sensor) - **Hold-to-activate:** Houd de knop ingedrukt voor relais aan, loslaten voor relais uit (zelfde gedrag als de sensor)
- **BLE Pairing:** De app initieert bonding (`createBond()`) na connectie; Just Works pairing verloopt zonder gebruikersinteractie
- **MTU negotiatie:** Vraagt MTU 256 aan na service discovery (voor 37-byte HMAC packets)
- **HMAC authenticatie:** Elk commando wordt ondertekend met HMAC-SHA256 + sequence counter
- **Instellingenscherm:** Losse Activity voor het invoeren van de HMAC key (met oog-icoon om de key te tonen/verbergen)
- **Permissies:** Vraagt BLUETOOTH_SCAN, BLUETOOTH_CONNECT en ACCESS_FINE_LOCATION aan (Android 12+) - **Permissies:** Vraagt BLUETOOTH_SCAN, BLUETOOTH_CONNECT en ACCESS_FINE_LOCATION aan (Android 12+)
### Instellingenscherm
1. Open de app
2. Tik op het **tandwiel-icoon** rechtsboven
3. Voer de 64-teken hex key in (zelfde als de ESP op serial print)
4. Tik op het **oog-icoon** om de key te tonen/verbergen
5. Tik **Opslaan**
De key wordt opgeslagen in SharedPreferences en overleeft app-herstarts. Bij geen key toont de app "Geen HMAC sleutel - open Instellingen" en wordt er niet gescant.
### Bouwen ### Bouwen
Vereisten: Vereisten:
- Android SDK (API 34+) - Android SDK (API 34+)
- Java 21 - Java 17+ (getest met Java 17 en 21; Java 26 werkt niet met Kotlin 1.9 daemon — valt terug op non-incremental)
- Gradle 8.5 - Gradle 8.2.2 (via wrapper)
```bash ```bash
cd android cd android
JAVA_HOME=/pad/naar/jdk21 ./gradlew assembleDebug ./gradlew assembleDebug
``` ```
De APK staat na build in `android/app/build/outputs/apk/debug/app-debug.apk`. De APK staat na build in `android/app/build/outputs/apk/debug/app-debug.apk`.
@ -121,22 +209,55 @@ adb connect <ip>:<poort>
adb install -r android/app/build/outputs/apk/debug/app-debug.apk adb install -r android/app/build/outputs/apk/debug/app-debug.apk
``` ```
### UUID's ## Eerste keer setup
De app gebruikt dezelfde UUID's als de firmware: 1. **Genereer HMAC key:**
```bash
python3 tools/generate_hmac_key.py
```
Noteer de geprinte hex key.
```kotlin 2. **Flash de ESP:**
private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") ```bash
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") pio run -t upload
``` ```
3. **Lees de HMAC key uit serial** (5 seconden na opstarten):
```bash
# Via python:
python3 -c "import serial,time; s=serial.Serial('/dev/ttyACM0',115200); [print(l) for l in iter(lambda: s.readline().decode(errors='replace').strip() or None, None) if 'HMAC Key:' in l] if False else exec('''
import serial, time
s = serial.Serial(\"/dev/ttyACM0\", 115200, timeout=2)
start = time.time()
while time.time() - start < 15:
d = s.read(1024)
if d:
for line in d.decode(errors=\"replace\").splitlines():
if line.strip(): print(line.strip())
if b\"HMAC Key:\" in d: break
s.close()
''')"
```
4. **Installeer de app op de telefoon:**
```bash
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
```
5. **Open de app → tandwiel-icoon → voer key in → Opslaan**
6. De app scant, paart (Just Works), en verbindt. Houd de HOLD-knop ingedrukt om het relais te schakelen.
## Projectstructuur ## Projectstructuur
``` ```
sylvia-touch-relay/ sylvia-touch-relay/
├── platformio.ini # PlatformIO configuratie (ESP32-S3) ├── platformio.ini # PlatformIO configuratie (ESP32-S3)
├── tools/
│ └── generate_hmac_key.py # CLI tool: genereert src/hmac_key.h
├── src/ ├── src/
│ └── main.cpp # Firmware (BLE + sensor + relais) │ ├── main.cpp # Firmware (BLE + HMAC + sensor + relais)
│ └── hmac_key.h # Gegenereerd, niet in git (.gitignore)
├── android/ ├── android/
│ ├── build.gradle # Root Gradle config │ ├── build.gradle # Root Gradle config
│ ├── settings.gradle │ ├── settings.gradle
@ -149,9 +270,16 @@ sylvia-touch-relay/
│ └── src/main/ │ └── src/main/
│ ├── AndroidManifest.xml │ ├── AndroidManifest.xml
│ ├── java/com/eddy/atomrelay/ │ ├── java/com/eddy/atomrelay/
│ │ └── MainActivity.kt │ │ ├── MainActivity.kt # BLE client + HMAC signing
│ │ └── SettingsActivity.kt # HMAC key invoer + oog toggle
│ └── res/ │ └── res/
│ ├── layout/activity_main.xml │ ├── drawable/
│ │ ├── ic_eye_open.xml # Oog open icoon
│ │ ├── ic_eye_closed.xml # Oog dicht icoon
│ │ └── ic_settings.xml # Tandwiel icoon
│ ├── layout/
│ │ ├── activity_main.xml # Hoofd UI
│ │ └── activity_settings.xml # Key invoer UI
│ └── values/strings.xml │ └── values/strings.xml
├── .gitignore ├── .gitignore
└── README.md └── README.md
@ -161,5 +289,9 @@ sylvia-touch-relay/
- De Atom VoiceS3R gebruikt de ES8311 audio codec op G45/G0/G48/G4/G3/G17/G11, IR TX op G47, en de knop op G41. Deze pinnen zijn niet beschikbaar voor eigen gebruik. - De Atom VoiceS3R gebruikt de ES8311 audio codec op G45/G0/G48/G4/G3/G17/G11, IR TX op G47, en de knop op G41. Deze pinnen zijn niet beschikbaar voor eigen gebruik.
- G1 en G2 zitten op de HY2.0-4P Grove poort. G5-G8, G38, G39 zitten op de onderste expantie-header. - G1 en G2 zitten op de HY2.0-4P Grove poort. G5-G8, G38, G39 zitten op de onderste expantie-header.
- De firmware gebruikt geen M5Unified library — alleen Arduino core + ESP32 BLE. Dit maakt de serial output betrouwbaarder via USB CDC. - De firmware gebruikt geen M5Unified library — alleen Arduino core + ESP32 BLE + mbedtls. Dit maakt de serial output betrouwbaarder via USB CDC.
- `ARDUINO_USB_CDC_ON_BOOT=1` is ingeschakeld voor virtuele serial via USB. - `ARDUINO_USB_CDC_ON_BOOT=1` is ingeschakeld voor virtuele serial via USB.
- De HMAC key wordt via `#include "hmac_key.h"` in de firmware geincludeerd. Dit bestand is gegenereerd door `tools/generate_hmac_key.py` en staat in `.gitignore`.
- De app gebruikt `WRITE_TYPE_NO_RESPONSE` voor minimale latency — de encryptie en HMAC verificatie gebeuren op de link layer resp. application layer en zijn onafhankelijk van het write type.
- De sequence counter start bij 1 (niet 0) om te voorkomen dat het eerste packet na connectie als replay wordt geweerd (`0 <= 0` is true in de `seq <= lastSeq` check).
- Für meerdere ESP modules met verschillende keys: genereer per module een eigen key, flash de firmware, en voer de juiste key in de app in. De app ondersteunt momenteel slechtséén key (opgeslagen in SharedPreferences).

View file

@ -24,5 +24,10 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".SettingsActivity"
android:label="Instellingen"
android:parentActivityName=".MainActivity"/>
</application> </application>
</manifest> </manifest>

View file

@ -11,6 +11,10 @@ import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@ -18,9 +22,14 @@ import android.os.Looper
import android.util.Log import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.Button import android.widget.Button
import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.UUID import java.util.UUID
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -31,6 +40,8 @@ class MainActivity : AppCompatActivity() {
private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val DEVICE_NAME = "AtomS3R-Relay" private const val DEVICE_NAME = "AtomS3R-Relay"
private const val HMAC_PKT_SIZE = 37 // 1 cmd + 4 seq + 32 hmac
private const val HMAC_LEN = 32
} }
private var bluetoothGatt: BluetoothGatt? = null private var bluetoothGatt: BluetoothGatt? = null
@ -38,6 +49,10 @@ class MainActivity : AppCompatActivity() {
private var isScanning = false private var isScanning = false
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var seq = 1
private var hmacKey: ByteArray? = null
private var pendingDevice: BluetoothDevice? = null
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var relayButton: Button private lateinit var relayButton: Button
private lateinit var scanButton: Button private lateinit var scanButton: Button
@ -54,11 +69,39 @@ class MainActivity : AppCompatActivity() {
} }
} }
private val bondReceiver = object : BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
val bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)
Log.d(TAG, "Bond state changed: ${device?.address} -> $bondState")
if (bondState == BluetoothDevice.BOND_BONDED && device == pendingDevice) {
Log.d(TAG, "Bonded, discovering services...")
runOnUiThread { statusText.text = "Bonded, connecting..." }
bluetoothGatt?.discoverServices()
}
}
}
}
private val gattCallback = object : BluetoothGattCallback() { private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) { if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.d(TAG, "GATT connected, discovering services...") Log.d(TAG, "GATT connected")
bluetoothGatt = gatt
val device = gatt.device
pendingDevice = device
if (device.bondState == BluetoothDevice.BOND_BONDED) {
Log.d(TAG, "Already bonded, discovering services...")
runOnUiThread { statusText.text = "Connected, discovering..." }
gatt.discoverServices() gatt.discoverServices()
} else {
Log.d(TAG, "Not bonded, creating bond...")
runOnUiThread { statusText.text = "Pairing..." }
device.createBond()
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) { } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected") Log.d(TAG, "GATT disconnected")
runOnUiThread { runOnUiThread {
@ -68,23 +111,29 @@ class MainActivity : AppCompatActivity() {
gatt.close() gatt.close()
bluetoothGatt = null bluetoothGatt = null
relayCharacteristic = null relayCharacteristic = null
// Auto-reconnect after 2 seconds seq = 0
handler.postDelayed({ startScan() }, 2000) handler.postDelayed({ startScan() }, 2000)
} }
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
Log.d(TAG, "Services discovered, requesting MTU...")
gatt.requestMtu(256)
}
@SuppressLint("MissingPermission")
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to $mtu")
val service = gatt.getService(SERVICE_UUID) val service = gatt.getService(SERVICE_UUID)
if (service != null) { if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
if (relayCharacteristic != null) { if (relayCharacteristic != null) {
// Enable notifications
gatt.setCharacteristicNotification(relayCharacteristic, true) gatt.setCharacteristicNotification(relayCharacteristic, true)
val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID) val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor) gatt.writeDescriptor(descriptor)
Log.d(TAG, "Services discovered, relay characteristic found") Log.d(TAG, "Ready, relay characteristic found")
runOnUiThread { runOnUiThread {
statusText.text = "Connected" statusText.text = "Connected"
relayButton.isEnabled = true relayButton.isEnabled = true
@ -112,14 +161,59 @@ class MainActivity : AppCompatActivity() {
statusText = findViewById(R.id.statusText) statusText = findViewById(R.id.statusText)
relayButton = findViewById(R.id.relayButton) relayButton = findViewById(R.id.relayButton)
scanButton = findViewById(R.id.scanButton) scanButton = findViewById(R.id.scanButton)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
scanButton.setOnClickListener { startScan() } scanButton.setOnClickListener { startScan() }
settingsButton.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
setupRelayButton() setupRelayButton()
// Auto-connect on app start registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
handler.postDelayed({ startScan() }, 500) handler.postDelayed({ startScan() }, 500)
} }
override fun onResume() {
super.onResume()
loadHmacKey()
if (hmacKey == null) {
statusText.text = "Geen HMAC sleutel - open Instellingen"
}
}
private fun loadHmacKey() {
val prefs = getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE)
val hexKey = prefs.getString(SettingsActivity.PREF_KEY, null)
hmacKey = hexKey?.let { hexStringToBytes(it) }
Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}")
}
private fun hexStringToBytes(hex: String): ByteArray? {
val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" }
if (cleaned.length != 64) return null
return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
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)
return packet
}
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
@ -160,6 +254,11 @@ class MainActivity : AppCompatActivity() {
return return
} }
if (hmacKey == null) {
statusText.text = "Geen HMAC sleutel - open Instellingen"
return
}
isScanning = true isScanning = true
statusText.text = "Scanning..." statusText.text = "Scanning..."
scanButton.isEnabled = false scanButton.isEnabled = false
@ -167,7 +266,6 @@ class MainActivity : AppCompatActivity() {
adapter.bluetoothLeScanner.startScan(scanCallback) adapter.bluetoothLeScanner.startScan(scanCallback)
// Stop scan after 10 seconds
handler.postDelayed({ handler.postDelayed({
if (isScanning) stopScan() if (isScanning) stopScan()
}, 10000) }, 10000)
@ -196,10 +294,15 @@ class MainActivity : AppCompatActivity() {
private fun sendRelayCommand(on: Boolean) { private fun sendRelayCommand(on: Boolean) {
val char = relayCharacteristic ?: return val char = relayCharacteristic ?: return
val gatt = bluetoothGatt ?: return val gatt = bluetoothGatt ?: return
char.value = byteArrayOf(if (on) 0x01 else 0x00) val key = hmacKey ?: return
val cmd: Byte = if (on) 0x01 else 0x00
val packet = buildPacket(cmd, key)
char.value = packet
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char) gatt.writeCharacteristic(char)
Log.d(TAG, "Sent relay ${if (on) "ON" else "OFF"}") seq++
Log.d(TAG, "Sent relay ${if (on) "ON" else "OFF"} (seq=${seq - 1})")
} }
private fun getRequiredPermissions(): Array<String> { private fun getRequiredPermissions(): Array<String> {
@ -224,9 +327,11 @@ class MainActivity : AppCompatActivity() {
ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1) ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1)
} }
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
stopScan() stopScan()
bluetoothGatt?.close() bluetoothGatt?.close()
unregisterReceiver(bondReceiver)
} }
} }

View file

@ -0,0 +1,90 @@
package com.eddy.atomrelay
import android.content.Context
import android.os.Bundle
import android.text.InputType
import android.text.TextWatcher
import android.text.Editable
import android.widget.Button
import android.widget.EditText
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
class SettingsActivity : AppCompatActivity() {
companion object {
const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key"
}
private var isVisible = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Instellingen"
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val keyInput = findViewById<EditText>(R.id.keyInput)
val toggleBtn = findViewById<ImageButton>(R.id.toggleVisibility)
val saveButton = findViewById<Button>(R.id.saveButton)
val backButton = findViewById<Button>(R.id.backButton)
val keyError = findViewById<TextView>(R.id.keyError)
keyInput.setText(prefs.getString(PREF_KEY, ""))
toggleBtn.setOnClickListener {
isVisible = !isVisible
if (isVisible) {
keyInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
toggleBtn.setImageResource(R.drawable.ic_eye_open)
} else {
keyInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
toggleBtn.setImageResource(R.drawable.ic_eye_closed)
}
keyInput.setSelection(keyInput.text.length)
}
keyInput.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
val cleaned = s?.toString()?.uppercase()?.filter { it in "0123456789ABCDEF" }
if (cleaned != null && cleaned != s.toString()) {
keyInput.removeTextChangedListener(this)
keyInput.setText(cleaned)
keyInput.setSelection(cleaned.length)
keyInput.addTextChangedListener(this)
}
val len = cleaned?.length ?: 0
if (len == 0 || len == 64) {
keyError.visibility = TextView.GONE
} else {
keyError.text = "Sleutel moet 64 hex tekens zijn (nu $len)"
keyError.visibility = TextView.VISIBLE
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
saveButton.setOnClickListener {
val key = keyInput.text.toString().uppercase().filter { it in "0123456789ABCDEF" }
if (key.length != 64) {
keyError.text = "Sleutel moet 64 hex tekens zijn (nu ${key.length})"
keyError.visibility = TextView.VISIBLE
return@setOnClickListener
}
prefs.edit().putString(PREF_KEY, key).apply()
finish()
}
backButton.setOnClickListener { finish() }
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorSecondary">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.44 1.78,10.07 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorSecondary">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorSecondary">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94 0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94l-0.36,-2.54c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41l-0.36,2.54c-0.59,0.24 -1.13,0.56 -1.62,0.94l-2.39,-0.96c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87c-0.12,0.21 -0.08,0.47 0.12,0.61l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6 -1.62,3.6 -3.6,3.6z"/>
</vector>

View file

@ -6,6 +6,16 @@
android:gravity="center" android:gravity="center"
android:padding="32dp"> android:padding="32dp">
<ImageButton
android:id="@+id/settingsButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="end"
android:src="@drawable/ic_settings"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Instellingen"
android:layout_marginBottom="16dp" />
<TextView <TextView
android:id="@+id/statusText" android:id="@+id/statusText"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HMAC Sleutel"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Voer de 64-teken hex sleutel in die de ESP op serial print na opstarten."
android:textSize="14sp"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<EditText
android:id="@+id/keyInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="C3F17B61..."
android:inputType="textPassword"
android:fontFamily="monospace"
android:textSize="16sp"
android:maxLength="64"
android:letterSpacing="0.05"
android:autofillHints=""
android:layout_marginEnd="8dp" />
<ImageButton
android:id="@+id/toggleVisibility"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_eye_closed"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="Toon/verberg sleutel" />
</LinearLayout>
<TextView
android:id="@+id/keyError"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/holo_red_dark"
android:textSize="14sp"
android:visibility="gone"
android:layout_marginTop="8dp" />
<Button
android:id="@+id/saveButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Opslaan"
android:layout_marginTop="24dp" />
<Button
android:id="@+id/backButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Terug"
android:background="@android:color/transparent"
android:layout_marginTop="8dp" />
</LinearLayout>

View file

@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Atom Relay</string> <string name="app_name">Atom Relay</string>
<string name="settings">Instellingen</string>
<string name="hmac_key_hint">C3F17B61...</string>
<string name="save">Opslaan</string>
<string name="back">Terug</string>
</resources> </resources>

View file

@ -3,6 +3,9 @@
#include <BLEServer.h> #include <BLEServer.h>
#include <BLEUtils.h> #include <BLEUtils.h>
#include <BLE2902.h> #include <BLE2902.h>
#include <BLESecurity.h>
#include <mbedtls/md.h>
#include "hmac_key.h"
#define SENSOR_PIN 38 #define SENSOR_PIN 38
#define RELAY_PIN 1 #define RELAY_PIN 1
@ -11,36 +14,85 @@
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8" #define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361f26d8"
#define HMAC_PKT_SIZE 37 // 1 cmd + 4 seq + 32 hmac
BLECharacteristic *pCharacteristic; BLECharacteristic *pCharacteristic;
bool deviceConnected = false; bool deviceConnected = false;
bool bleCommand = false; bool bleCommand = false;
bool relayState = false; bool relayState = false;
uint32_t lastSeq = 0;
class MyServerCallbacks : public BLEServerCallbacks { class MyServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer *pServer) override { void onConnect(BLEServer *pServer) override {
deviceConnected = true; deviceConnected = true;
lastSeq = 0;
Serial.println("BLE: device connected"); Serial.println("BLE: device connected");
} }
void onDisconnect(BLEServer *pServer) override { void onDisconnect(BLEServer *pServer) override {
deviceConnected = false; deviceConnected = false;
bleCommand = false; bleCommand = false;
lastSeq = 0;
Serial.println("BLE: device disconnected"); Serial.println("BLE: device disconnected");
BLEDevice::startAdvertising(); BLEDevice::startAdvertising();
} }
}; };
static bool verifyHmac(const uint8_t *packet) {
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];
if (seq <= lastSeq) {
Serial.printf("BLE: replay rejected (seq=%lu, last=%lu)\n", seq, lastSeq);
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 computed[32];
const mbedtls_md_info_t *mdInfo = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_hmac(mdInfo, HMAC_KEY, 32, hmacData, 5, computed);
if (memcmp(computed, packet + 5, 32) != 0) {
Serial.println("BLE: HMAC mismatch");
return false;
}
lastSeq = seq;
return true;
}
class MyCharacteristicCallbacks : public BLECharacteristicCallbacks { class MyCharacteristicCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pChar) override { void onWrite(BLECharacteristic *pChar) override {
std::string value = pChar->getValue(); std::string value = pChar->getValue();
if (value.length() > 0) { if (value.length() != HMAC_PKT_SIZE) {
uint8_t cmd = (uint8_t)value[0]; Serial.printf("BLE: bad packet length %d (expected %d)\n",
(int)value.length(), HMAC_PKT_SIZE);
return;
}
const uint8_t *pkt = (const uint8_t *)value.data();
if (!verifyHmac(pkt)) {
return;
}
uint8_t cmd = pkt[0];
uint32_t seq = lastSeq;
if (cmd == 0x01) { if (cmd == 0x01) {
bleCommand = true; bleCommand = true;
Serial.println("BLE: relay ON"); Serial.printf("BLE: relay ON (seq=%lu)\n", seq);
} else { } else {
bleCommand = false; bleCommand = false;
Serial.println("BLE: relay OFF"); Serial.printf("BLE: relay OFF (seq=%lu)\n", seq);
}
} }
} }
}; };
@ -55,7 +107,6 @@ void setup() {
pinMode(RELAY_PIN, OUTPUT); pinMode(RELAY_PIN, OUTPUT);
pinMode(LED_PIN, OUTPUT); pinMode(LED_PIN, OUTPUT);
// Visual confirmation: blink LED 3x before BLE init
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
digitalWrite(LED_PIN, HIGH); digitalWrite(LED_PIN, HIGH);
delay(200); delay(200);
@ -65,7 +116,12 @@ void setup() {
Serial.println("LED blink done, starting BLE..."); Serial.println("LED blink done, starting BLE...");
BLEDevice::init("AtomS3R-Relay"); BLEDevice::init("AtomS3R-Relay");
BLEDevice::setMTU(23);
BLESecurity *pSecurity = new BLESecurity();
pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_BOND);
pSecurity->setCapability(ESP_IO_CAP_NONE);
pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
BLEServer *pServer = BLEDevice::createServer(); BLEServer *pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks()); pServer->setCallbacks(new MyServerCallbacks());
@ -74,6 +130,7 @@ void setup() {
CHARACTERISTIC_UUID, CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY
); );
pCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED);
pCharacteristic->addDescriptor(new BLE2902()); pCharacteristic->addDescriptor(new BLE2902());
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks()); pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
pCharacteristic->setValue("0"); pCharacteristic->setValue("0");
@ -87,6 +144,16 @@ void setup() {
BLEDevice::startAdvertising(); BLEDevice::startAdvertising();
Serial.println("BLE: advertising started as 'AtomS3R-Relay'"); Serial.println("BLE: advertising started as 'AtomS3R-Relay'");
Serial.println("Printing HMAC key in 5 seconds...");
delay(5000);
Serial.print("HMAC Key: ");
for (int i = 0; i < 32; i++) {
Serial.printf("%02X", HMAC_KEY[i]);
}
Serial.println();
Serial.println("Enter this key in the Android app > Settings.");
Serial.println("Setup done."); Serial.println("Setup done.");
} }

View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Generate src/hmac_key.h with a random 32-byte HMAC-SHA256 key.
Usage:
python3 tools/generate_hmac_key.py
This creates src/hmac_key.h (gitignored) and prints the hex key to stdout.
The hex key should be entered in the Android app's Settings screen.
"""
import os
import sys
import binascii
KEY_LENGTH = 32
OUTPUT_FILE = os.path.join("src", "hmac_key.h")
def generate():
key = os.urandom(KEY_LENGTH)
hex_key = binascii.hexlify(key).decode().upper()
header = f"""#ifndef HMAC_KEY_H
#define HMAC_KEY_H
// AUTO-GENERATED by tools/generate_hmac_key.py
// Do NOT commit this file. Do NOT edit by hand.
// To regenerate: python3 tools/generate_hmac_key.py
const uint8_t HMAC_KEY[{KEY_LENGTH}] = {{
{', '.join(f'0x{b:02x}' for b in key)}
}};
#endif // HMAC_KEY_H
"""
os.makedirs("src", exist_ok=True)
with open(OUTPUT_FILE, "w") as f:
f.write(header)
print(f"HMAC key written to {OUTPUT_FILE}")
print(f"Enter this key in the Android app Settings:")
print(f" {hex_key}")
print()
print(f"ESP serial output after boot will show the same key.")
if __name__ == "__main__":
if os.path.exists(OUTPUT_FILE):
overwrite = input(f"{OUTPUT_FILE} already exists. Overwrite? [y/N] ")
if overwrite.lower() != 'y':
print("Aborted.")
sys.exit(0)
generate()