Simple Application-level Authentication

Introduction

There is a use case that I've seen several times recently that I find interesting due to the fact that its security requirements aren't directly addressed by the Bluetooth Low Energy (BLE) standard. It goes something like this:

I have a BLE device that multiple users can interact with. Although most of the BLE characteristics should be open, one or more 'admin' characteristics should only be modifiable if the user has permissions to change them.

I will present a simple solution using the nRF52832 with the S132 SoftDevice.

Overview

The 128-bit AES ECB peripheral provides a secure and efficient method for signing 128-bit blocks of data as long as the blocks are generated fresh before each transaction. The process looks like this:

  1. The firmware and the phone are both in possession of a pre-shared, 128-bit key.
  2. The firmware uses the Random Number Generator peripheral to generate a unique, 128-bit NONCE before advertising.
  3. The firmware makes this NONCE readable via an 'authentication' characteristic so the phone app can read it.
  4. The firmware and the phone app both encrypt the NONCE using the 128-bit AES ECB cipher to create the challenge response.
  5. The phone app writes the challenge response to the 'authentication' characteristic.
  6. The firmware compares the phone's challenge response to its own challenge response to ensure that the phone app knows the pre-shared key.
  7. If the authentication is successful then further access to the 'admin' characterstics is allowed until the phone disconnects. If the phone's challenge response is incorrect then the firmware disconnects immediately. Similarly, if the phone tries to access an 'admin' characteristic before authenticating then the firmware disconnects immediately.
  8. After disconnecting, the firmware generates a unique NONCE before advertising again.

Implementation

I will add an 'authentication' characterstic to the Nordic UART Service (NUS) and assume that writes to the NUS' TX characterstic requires authentication. If you are going to allow for multiple instances of a BLE service then it's a good idea to put instance-specific information in your service's struct. Adding an 'authentication' characteristic to the NUS might look like this:

struct ble_nus_ecb_t
{
    uint8_t                    uuid_type;               /**< UUID type for Nordic UART Service Base UUID. */
    uint16_t                   service_handle;          /**< Handle of Nordic UART Service (as provided by the SoftDevice). */
    ble_gatts_char_handles_t   tx_handles;              /**< Handles related to the TX characteristic (as provided by the SoftDevice). */
    ble_gatts_char_handles_t   rx_handles;              /**< Handles related to the RX characteristic (as provided by the SoftDevice). */
    ble_gatts_char_handles_t   auth_handles;            /**< Handles related to the Authentication characteristic (as provided by the SoftDevice). */
    uint16_t                   conn_handle;             /**< Handle of the current connection (as provided by the SoftDevice). BLE_CONN_HANDLE_INVALID if not in a connection. */
    bool                       is_rx_notification_enabled; /**< Variable to indicate if the peer has enabled notification of the RX characteristic.*/
    bool                       is_auth_notification_enabled; /**< Variable to indicate if the peer has enabled notification of the AUTH characteristic.*/
    ble_nus_ecb_data_handler_t data_handler;            /**< Event handler to be called for handling received data. */
    uint8_t                    ecb_key[SOC_ECB_KEY_LENGTH];
    uint8_t                    ecb_nonce[SOC_ECB_KEY_LENGTH];
    uint8_t                    ecb_response[SOC_ECB_KEY_LENGTH];
    bool                       authorized;
};

Characteristics have properties that define the operations that are permitted to be used on them. Normally, a phone app would use the “write without response” operation to write new values to characteristics because that operation has the least amount of protocol overhead. However, if you disable the “write_wo_resp” operation on a particular characteristic then the phone app will be forced to write a value to the characteristic and then wait for your firmware to respond before the phone app can write another value. This is step one.

char_md.char_props.write         = 1;
char_md.char_props.write_wo_resp = 0; // Disable write wo response.
char_md.p_char_user_desc         = NULL;
char_md.p_char_pf                = NULL;
char_md.p_user_desc_md           = NULL;
char_md.p_cccd_md                = NULL;
char_md.p_sccd_md                = NULL;

Next, consider that when you create the characteristics in your firmware you also create “attribute metadata” for them. The most prominent part of this metadata is the part where it defines how much security is required to read or write your characteristic (e.g. “BLE_GAP_CONN_SEC_MODE_SET_OPEN(&attr_md.write_perm)”). But there is also a “wr_auth” field that allows you to enable “write authorization” for a particular characteristic. If write authorization is enabled for a characteristic then incoming write commands from the phone app will be sent as “write requests with authorization”. Now whenever the phone app tries to change one of your characteristics your firmware will receive the request and be allowed to approve or reject it.

attr_md.vloc    = BLE_GATTS_VLOC_STACK;
attr_md.rd_auth = 0;
attr_md.wr_auth = 1; // Enable write authorization
attr_md.vlen    = 1;

You will still receive the normal BLE_GATTS_EVT_WRITE when the phone app writes to your regular characteristics and CCCD values. But writes to your 'admin' characteristics will now be received via BLE_GATTS_EVT_RW_AUTHORIZE_REQUEST events:

void ble_nus_ecb_on_ble_evt(ble_nus_ecb_t * p_nus, ble_evt_t * p_ble_evt)
{
    ...
    switch (p_ble_evt->header.evt_id)
    {
    case BLE_GATTS_EVT_WRITE:
        // Write without response characteristics and CCCD values will be
        // received here.
        on_write(p_nus, p_ble_evt);
        break;

    case BLE_GATTS_EVT_RW_AUTHORIZE_REQUEST: 
    {
        ble_gatts_evt_rw_authorize_request_t  *req;

        req = &p_ble_evt->evt.gatts_evt.params.authorize_request;

        if (BLE_GATTS_AUTHORIZE_TYPE_INVALID != req->type) 
        {
            if ((BLE_GATTS_AUTHORIZE_TYPE_WRITE == req->type) && 
                (BLE_GATTS_OP_WRITE_REQ == req->request.write.op))
            {
                on_write_req(p_nus, p_ble_evt);
            }
            else if ((req->request.write.op == BLE_GATTS_OP_PREP_WRITE_REQ)     || 
                     (req->request.write.op == BLE_GATTS_OP_EXEC_WRITE_REQ_NOW) || 
                     (req->request.write.op == BLE_GATTS_OP_EXEC_WRITE_REQ_CANCEL)) 
            {
                if (req->type == BLE_GATTS_AUTHORIZE_TYPE_WRITE) 
                { 
                    auth_reply_error(p_ble_evt, BLE_GATTS_AUTHORIZE_TYPE_WRITE);
                } 
                else 
                { 
                    auth_reply_error(p_ble_evt, BLE_GATTS_AUTHORIZE_TYPE_READ);
                } 
            }
        }
    }
        break;
    ...
    }
}

The auth_reply_error and auth_reply_success functions could look like this:

#define APP_FEATURE_NOT_SUPPORTED      (BLE_GATT_STATUS_ATTERR_APP_BEGIN + 2)
...
static void auth_reply_error(ble_evt_t * p_ble_evt, uint16_t type)
{
    uint32_t                              err_code;
    ble_gatts_rw_authorize_reply_params_t auth_reply;

    auth_reply.type = type;
    auth_reply.params.write.gatt_status = APP_FEATURE_NOT_SUPPORTED; 

    err_code = sd_ble_gatts_rw_authorize_reply(p_ble_evt->evt.gatts_evt.conn_handle, 
                                               &auth_reply); 
    APP_ERROR_CHECK(err_code); 
}

static void auth_reply_success(ble_evt_t * p_ble_evt, ble_gatts_evt_write_t * p_wr_req)
{
    uint32_t                              err_code;
    ble_gatts_rw_authorize_reply_params_t auth_reply;

    auth_reply.type                     = BLE_GATTS_AUTHORIZE_TYPE_WRITE;
    auth_reply.params.write.gatt_status = BLE_GATT_STATUS_SUCCESS;
    auth_reply.params.write.len         = p_wr_req->len;
    auth_reply.params.write.p_data      = p_wr_req->data;
    auth_reply.params.write.offset      = 0;
    auth_reply.params.write.update      = 1;

    err_code = sd_ble_gatts_rw_authorize_reply(p_ble_evt->evt.gatts_evt.conn_handle, 
                                               &auth_reply); 
    APP_ERROR_CHECK(err_code); 
}

Generating a NONCE can be done the same way that it was done previously.

#define RNG_BYTE_WAIT_US               (124UL)
...
/**
 * @brief Uses the RNG to write a 16-byte nonce to a buffer
 *
 * @param[in]    p_buf    An array of length 16
 */
static void nonce_generate(uint8_t * p_buf)
{
    uint8_t i         = 0;
    uint8_t remaining = SOC_ECB_KEY_LENGTH;

    // The random number pool may not contain enough bytes at the moment so
    // a busy wait may be necessary.
    while(0 != remaining)
    {
        uint32_t err_code;
        uint8_t  available = 0;

        err_code = sd_rand_application_bytes_available_get(&available);
        APP_ERROR_CHECK(err_code);

        available = ((available > remaining) ? remaining : available);
        if (0 != available)
        {
            err_code = sd_rand_application_vector_get((p_buf + i), available);
            APP_ERROR_CHECK(err_code);

            i         += available;
            remaining -= available;
        }

        if (0 != remaining)
        {
            nrf_delay_us(RNG_BYTE_WAIT_US * remaining);
        }
    }
}

The challenge response can be generated like this:

static void reponse_calc(const uint8_t * p_key, const uint8_t * p_nonce, uint8_t * p_response)
{
    uint32_t           err_code;
    nrf_ecb_hal_data_t m_ecb_data;

    memcpy(&m_ecb_data.key[0],       p_key,   SOC_ECB_KEY_LENGTH);
    memcpy(&m_ecb_data.cleartext[0], p_nonce, SOC_ECB_KEY_LENGTH);

    err_code = sd_ecb_block_encrypt(&m_ecb_data);
    APP_ERROR_CHECK(err_code);

    memcpy(p_response, &m_ecb_data.ciphertext[0], SOC_ECB_KEY_LENGTH);
}

It's important that the NONCE is never re-used so make sure you refresh it every time a new connection is expected:

static void auth_refresh(ble_nus_ecb_t * p_nus)
{
    p_nus->authorized = false;
    nonce_generate(&p_nus->ecb_nonce[0]);
    reponse_calc(&p_nus->ecb_key[0],
                 &p_nus->ecb_nonce[0],
                 &p_nus->ecb_response[0]);
}

Now that generating the NONCE and the challenge response are sorted, the last thing to do is handle the actual write requests. Continuing with the idea of adding an 'authentication' characteristic to the NUS:

static void on_write_req(ble_nus_ecb_t * p_nus, ble_evt_t * p_ble_evt)
{
    uint32_t                err_code;
    ble_gatts_evt_write_t * p_wr_req;

    p_wr_req = &p_ble_evt->evt.gatts_evt.params.authorize_request.request.write;

    if (p_wr_req->handle == p_nus->auth_handles.value_handle)
    {
        // This is a write to the AUTH handle.
        if ((SOC_ECB_KEY_LENGTH == p_wr_req->len) &&
            (0 == memcmp(p_wr_req->data,
                         &p_nus->ecb_response[0],
                         SOC_ECB_KEY_LENGTH)))
        {
            // Correct challenge response received.
            ble_gatts_value_t gatts_val;

            p_nus->authorized = true;

            auth_reply_success(p_ble_evt, p_wr_req);

            // Write a confirmation value to the AUTH handle.
            gatts_val.len     = sizeof(AUTHORIZED_STR);
            gatts_val.offset  = 0;
            gatts_val.p_value = (uint8_t*)&AUTHORIZED_STR[0];

            err_code = sd_ble_gatts_value_set(p_nus->conn_handle,
                                              p_nus->auth_handles.value_handle,
                                              &gatts_val);
            APP_ERROR_CHECK(err_code);

            // Send a notification of the confirmation value.
            err_code = auth_handle_hvx_send(p_nus,
                                            (uint8_t*)&AUTHORIZED_STR[0],
                                            sizeof(AUTHORIZED_STR));
            APP_ERROR_CHECK(err_code);
        }
        else
        {
            // Incorrect challenge response. Disconnecting.
            auth_reply_error(p_ble_evt, BLE_GATTS_AUTHORIZE_TYPE_WRITE);
            err_code = sd_ble_gap_disconnect(p_nus->conn_handle,
                                             BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION);
                                             APP_ERROR_CHECK(err_code);
        }
    }
    else if (p_wr_req->handle == p_nus->tx_handles.value_handle)
    {
        // This is received UART data.
        if (p_nus->authorized)
        {
            auth_reply_success(p_ble_evt, p_wr_req);
            p_nus->data_handler(p_nus, p_wr_req->data, p_wr_req->len);
        }
        else
        {
            // Data written before authorization. Disconnecting.
            auth_reply_error(p_ble_evt, BLE_GATTS_AUTHORIZE_TYPE_WRITE);
            err_code = sd_ble_gap_disconnect(p_nus->conn_handle,
                                             BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION);
            APP_ERROR_CHECK(err_code);
        }
    }
    else
    {
        auth_reply_error(p_ble_evt, BLE_GATTS_AUTHORIZE_TYPE_WRITE);
    }
}

Additional Considerations

Be careful when hardcoding security keys into easily-accessible software before it is published. Some possible alternatives include:

  • Use the phone app to forward the NONCE from the device to a webservice and then reply with the challege response.
  • Give each device a unique key and then make that key available to the administrator of the device (e.g. via a sticker on the owner's manual). This allows the individual keys to be given to the phone application by the users instead of being baked into the software.
ble_nus_ecb.zip