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

View file

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

View file

@ -11,6 +11,10 @@ import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
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.os.Bundle
import android.os.Handler
@ -18,9 +22,14 @@ import android.os.Looper
import android.util.Log
import android.view.MotionEvent
import android.widget.Button
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
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
class MainActivity : AppCompatActivity() {
@ -31,6 +40,8 @@ class MainActivity : AppCompatActivity() {
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 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
@ -38,6 +49,10 @@ class MainActivity : AppCompatActivity() {
private var isScanning = false
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 relayButton: 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() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.d(TAG, "GATT connected, discovering services...")
gatt.discoverServices()
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()
} else {
Log.d(TAG, "Not bonded, creating bond...")
runOnUiThread { statusText.text = "Pairing..." }
device.createBond()
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d(TAG, "GATT disconnected")
runOnUiThread {
@ -68,23 +111,29 @@ class MainActivity : AppCompatActivity() {
gatt.close()
bluetoothGatt = null
relayCharacteristic = null
// Auto-reconnect after 2 seconds
seq = 0
handler.postDelayed({ startScan() }, 2000)
}
}
@SuppressLint("MissingPermission")
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)
if (service != null) {
relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
if (relayCharacteristic != null) {
// Enable notifications
gatt.setCharacteristicNotification(relayCharacteristic, true)
val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
Log.d(TAG, "Services discovered, relay characteristic found")
Log.d(TAG, "Ready, relay characteristic found")
runOnUiThread {
statusText.text = "Connected"
relayButton.isEnabled = true
@ -112,14 +161,59 @@ class MainActivity : AppCompatActivity() {
statusText = findViewById(R.id.statusText)
relayButton = findViewById(R.id.relayButton)
scanButton = findViewById(R.id.scanButton)
val settingsButton = findViewById<ImageButton>(R.id.settingsButton)
scanButton.setOnClickListener { startScan() }
settingsButton.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
setupRelayButton()
// Auto-connect on app start
registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
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")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
@ -160,6 +254,11 @@ class MainActivity : AppCompatActivity() {
return
}
if (hmacKey == null) {
statusText.text = "Geen HMAC sleutel - open Instellingen"
return
}
isScanning = true
statusText.text = "Scanning..."
scanButton.isEnabled = false
@ -167,7 +266,6 @@ class MainActivity : AppCompatActivity() {
adapter.bluetoothLeScanner.startScan(scanCallback)
// Stop scan after 10 seconds
handler.postDelayed({
if (isScanning) stopScan()
}, 10000)
@ -196,10 +294,15 @@ class MainActivity : AppCompatActivity() {
private fun sendRelayCommand(on: Boolean) {
val char = relayCharacteristic ?: 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
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> {
@ -224,9 +327,11 @@ class MainActivity : AppCompatActivity() {
ActivityCompat.requestPermissions(this, getRequiredPermissions(), 1)
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onDestroy() {
super.onDestroy()
stopScan()
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: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
android:id="@+id/statusText"
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"?>
<resources>
<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>