Implementing BLE Security on ESP32: LE Secure Connections the Hard Way
In my previous post, I talked about memory management challenges when building SecureGen. Today, let's dive into something equally painful: implementing proper BLE security.
The Problem: Passwords Over Bluetooth
My device needs to send passwords via BLE to any computer. But standard Bluetooth? Not secure enough.
If someone intercepts the pairing process, they can decrypt everything. For a password manager, that's catastrophic.
Enter LE Secure Connections
Bluetooth 4.2+ introduced LE Secure Connections - basically, proper encryption with MITM (Man-in-the-Middle) protection.
But getting it working on ESP32? That's another story.
Security Levels on ESP32
The ESP32 BLE stack has several security modes:
// BAD - No security
esp_ble_gap_set_security_param(ESP_BLE_SM_AUTHEN_REQ_MODE,
&auth_req, sizeof(uint8_t));
auth_req = ESP_LE_AUTH_NO_BOND;
// BETTER - Encryption but no MITM protection
auth_req = ESP_LE_AUTH_BOND;
// BEST - LE Secure Connections with MITM
auth_req = ESP_LE_AUTH_REQ_SC_MITM_BOND;
I went with the last one. Here's why:
- SC (Secure Connections) = Uses P-256 elliptic curve
- MITM = Prevents man-in-the-middle attacks
- BOND = Remembers paired devices
The Pairing Dance
With MITM protection, pairing requires user verification. ESP32 supports several methods:
1. Just Works - No verification (useless for security)
2. Passkey Entry - User types 6-digit code
3. Numeric Comparison - User confirms matching codes
Relevant files:
-
src/ble_keyboard.cpp- BLE HID implementation -
src/ble_security.cpp- Security configuration -
src/keyboard_layouts.h- Layout mappings I chose Numeric Comparison:
void gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param) {
switch(event) {
case ESP_GAP_BLE_NC_REQ_EVT:
// 6-digit PIN appears on both screens
uint32_t passkey = param->ble_security.key_notif.passkey;
// Display on device screen
display_pairing_code(passkey);
// User must confirm match on both devices
esp_ble_confirm_reply(param->ble_security.ble_req.bd_addr, true);
break;
}
}
When pairing, a 6-digit code appears on both the ESP32 screen and the computer. User verifies they match. If they don't match → someone's intercepting the connection.
iOS vs Android: The Nightmare
This is where things got painful.
Android: Works perfectly with default settings.
iOS: Refuses to pair. Just... refuses.
After days of debugging, I found the issue: iOS enforces stricter bonding requirements.
The Fix: Adaptive Bonding
void configure_bonding(bool is_ios) {
uint8_t key_size = 16; // Maximum encryption key size
uint8_t init_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK;
uint8_t rsp_key = ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK;
if (is_ios) {
// iOS requires these additional keys
init_key |= ESP_BLE_CSR_KEY_MASK;
rsp_key |= ESP_BLE_CSR_KEY_MASK;
}
esp_ble_gap_set_security_param(ESP_BLE_SM_MAX_KEY_SIZE,
&key_size, sizeof(uint8_t));
esp_ble_gap_set_security_param(ESP_BLE_SM_SET_INIT_KEY,
&init_key, sizeof(uint8_t));
esp_ble_gap_set_security_param(ESP_BLE_SM_SET_RSP_KEY,
&rsp_key, sizeof(uint8_t));
}
The CSR (Connection Signature Resolving) key is what iOS wants. Without it, pairing fails silently with no error messages.
Detecting iOS vs Android
But how do you know if the connecting device is iOS?
Turns out, you can check during the pairing process:
void gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param) {
switch(event) {
case ESP_GAP_BLE_AUTH_CMPL_EVT:
if (param->ble_security.auth_cmpl.success) {
// Check device properties
bool is_ios = check_device_capabilities(
param->ble_security.auth_cmpl.bd_addr
);
// Store for future connections
save_device_type(param->ble_security.auth_cmpl.bd_addr,
is_ios);
}
break;
}
}
I check the device's BLE capabilities during the first pairing and store it. Subsequent connections use the stored info.
BLE HID Keyboard Setup
Now that we have secure pairing, let's send passwords.
BLE HID (Human Interface Device) lets the ESP32 pretend to be a keyboard:
// HID Report Descriptor for keyboard
static const uint8_t hid_keyboard_report_map[] = {
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
// Modifier keys (Ctrl, Shift, Alt, etc.)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (Left Ctrl)
0x29, 0xE7, // Usage Maximum (Right GUI)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute)
// Regular keys
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x2A, 0xFF, 0x00, // Usage Maximum (255)
0x81, 0x00, // Input (Data, Array)
0xC0 // End Collection
};
This descriptor tells the OS: "I'm a keyboard with modifier keys and 6 simultaneous key presses."
Keyboard Layout Hell
Here's a fun problem: Different keyboard layouts handle special characters differently.
@ symbol:
US layout: Shift + 2
UK layout: Shift + '
German: AltGr + Q
French: AltGr + à
My solution: configurable layout mapping
typedef struct {
char character;
uint8_t keycode;
uint8_t modifier;
} KeyMapping;
KeyMapping us_layout[] = {
{'@', 0x1F, SHIFT}, // @ = Shift+2
{'#', 0x20, SHIFT}, // # = Shift+3
{'$', 0x21, SHIFT}, // $ = Shift+4
// ...
};
KeyMapping uk_layout[] = {
{'@', 0x34, SHIFT}, // @ = Shift+'
{'#', 0x32, NONE}, // # = just 3
// ...
};
void type_character(char c, KeyMapping *layout) {
KeyMapping *mapping = find_mapping(c, layout);
if (mapping) {
send_key_press(mapping->modifier, mapping->keycode);
}
}
Users select their keyboard layout in the web interface. The device translates characters to the correct key combinations.
Security: It's Not Just Encryption
Even with encrypted BLE, you need additional protections:
1. PIN Protection Before Transmission
void send_password_via_ble() {
// Require PIN on device screen
if (!verify_user_pin()) {
display_error("PIN required");
return;
}
// Show which password will be sent
display_confirm_screen(password_name);
// Wait for button confirmation (both buttons)
if (!wait_for_button_confirmation()) {
return;
}
// Only now send via BLE
ble_keyboard_type(password);
}
2. Bonding Management
// Store bonded devices
#define MAX_BONDED_DEVICES 5
void manage_bonding() {
uint8_t bonded_count = esp_ble_get_bond_device_num();
if (bonded_count >= MAX_BONDED_DEVICES) {
// Remove oldest bonded device
esp_ble_bond_dev_t *devices = malloc(sizeof(esp_ble_bond_dev_t)
* bonded_count);
esp_ble_get_bond_device_list(&bonded_count, devices);
esp_ble_remove_bond_device(devices[0].bd_addr);
free(devices);
}
}
3. Timeout Protection
void ble_transmission_task() {
// Connect
ble_connect();
// Send password
ble_keyboard_type(password);
// CRITICAL: Disconnect after transmission
vTaskDelay(pdMS_TO_TICKS(1000)); // Wait for typing to complete
ble_disconnect();
// Disable BLE completely after use
ble_deinit();
}
BLE only active during password transmission. Rest of the time, it's off.
Memory Considerations
Remember from my first post: BLE stack uses ~70KB RAM.
When sending passwords:
void safe_ble_transmission() {
// Check heap before enabling BLE
if (ESP.getFreeHeap() < 80000) {
display_error("Low memory");
return;
}
// Disable WiFi first
WiFi.disconnect();
WiFi.mode(WIFI_OFF);
delay(100);
// Now safe to enable BLE
init_ble_keyboard();
send_password();
deinit_ble_keyboard();
}
Testing BLE Security
How do you verify it's actually secure?
1. Wireshark BLE Capture:
Use an Ubertooth or nRF52840 to capture BLE packets. With proper LE Secure Connections, packets should be:
- Encrypted with AES-128
- Indecipherable without the pairing key
- Protected by message integrity checks
2. Try MITM Attack:
Use btlejack or similar tools to attempt interception:
btlejack -f 0x9c68 -t -m 0x1fffffffff
If implemented correctly, the attack should fail because the numeric comparison prevents MITM.
3. iOS Security Check:
On iOS, check Settings → Bluetooth → Device Info. Look for:
- "Encrypted" badge
- "Security: High"
Lessons Learned
- iOS is picky - Android will pair with almost anything, iOS won't.
- Numeric Comparison is worth it - Slight UX friction for massive security gain.
- Disconnect aggressively - BLE should be off when not transmitting.
- Test on real devices - Emulators don't catch bonding issues.
- Keyboard layouts matter - International users exist!
What's Next?
In the next post, I'll cover:
- Generating hardware encryption keys from ESP32 chip parameters
- Key derivation with PBKDF2
- Why flash wear leveling matters for security devices
The Code
Full implementation: SecureGen GitHub
Questions? Comments? Drop them below! I'm still learning and would love to hear how others solved similar problems.
esp32 #bluetooth #security #embedded #ble #encryption
Top comments (0)