DEV Community

makepkg
makepkg

Posted on

Implementing BLE Security on ESP32: LE Secure Connections the Hard Way

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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 + à
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. iOS is picky - Android will pair with almost anything, iOS won't.
  2. Numeric Comparison is worth it - Slight UX friction for massive security gain.
  3. Disconnect aggressively - BLE should be off when not transmitting.
  4. Test on real devices - Emulators don't catch bonding issues.
  5. 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



Enter fullscreen mode Exit fullscreen mode

Top comments (0)