【重要】LINBLE-Z2量産販売開始およびLINBLE-Z1使用部品変更予定のお知らせ

M5StackでBluetooth® LE通信を実現するまでの手順メモ⑧ペアリング実践編

こんにちは、ムセンコネクトCCOの伊藤です。(プロフィール紹介はこちら

M5StackでBluetooth® LE(BLE)通信を実現するまでの手順メモ」の第8回目です。今回は「ペアリング実践編」です。

ご注意ください

M5Stack自体はBluetooth認証を取得していないため、M5Stackで開発した最終製品のBluetooth認証取得は困難な場合があります。M5Stackを用いた製品化をご検討の際は、この点に十分ご注意ください。

M5Stackの公式サイトで製品を検索しても「Bluetooth」というキーワードでは見つからない
目次

「ペアリング」のおさらい

さて、今回はペアリングを試してみます。

ペアリングという言葉にピンとこない方や、復習したい方は、こちらのページでわかりやすく解説していますので、併せてご確認ください。

Bluetooth Core 4.2から登場した、LE Secure Connectionをベースに説明したいとおもいます。

「ペアリング」とは一言でいうと「通信相手を覚えさせてペアにすること」です(覚えさせておくこと自体はボンディングといいます)。

細かい部分は割愛しますが、Bluetooth LEのペアリング認証方式は以下の条件において、各デバイスの入出力能力(IO Capabilities)の組み合わせによって決定されます。

条件
  • 認証にBluetooth LE通信のみを用いる(NFCなどのOut of Band方式は利用しない)
  • 中間者攻撃(Man in the Middle、MITM)への対策を行う

入出力能力は以下に分類されます。

DisplayOnly表示(アウトプット)機能のみを有する
DisplayYesNo表示(アウトプット)機能のみを有する
KeyboardOnly任意の入力(インプット)機能のみを有する
NoInputNoOutput入出力機能を有さない
KeyboardDisplay任意の入力(インプット)と表示(アウトプット)機能を有する

これらの組み合わせから、以下のとおりに認証方式が決定されます。

IO Capabilitesの組み合わせによる認証方式一覧
Just Works

ユーザーの入力操作なしに接続相手を認証する簡易な方法。中間者攻撃対策を行わない場合もこの認証方式が採用されます。本当に意図した相手なのか判断がつき辛いのが難点です。

Passkey Entry

パスキーを入力してもらう方法。片方のデバイスが表示のみ、もう片方のデバイスに入力機能がある場合に選択されます。

Numeric Comparison

両方のデバイスに認証キーを表示して意図した接続相手か確認してもらう方法。両方のデバイスに入出力機能がある場合に選択されます。

M5CoreS3 SE(および搭載しているESP32)を使って、各認証方式を実践してみたいと思います。

確認用機材

確認するスマートフォンは社用の iPhone SE3 (iOS 18.5)、確認用アプリケーションは nRF Connect for Mobileを使用します。また、Numeric Comparisonによる認証ではYes/Noを選択して入力する必要があるため、偶然社内に転がっていたM5Stack用デュアルボタンユニット(Unit Dual Button )を活用します。

今回も基本的にAI(Gemini Pro 2.5)にコーディングをしてもらいますが、今回は望んだ認証方式が選択されるようにパラメータ等を正しく理解する必要があるので、M5Stackに搭載されているESP32のBluetoothのAPI仕様を確認しながら進めることにします。

AIに頼りきらず、自分で課題解決できるように知見を深めることが大事ですね。

実践!Just Works

まずは、Just Worksを体験してみましょう。

スマートフォン(KeyboardDisplay)を相手にする場合、MITM対策無しに設定することが最も簡単な方法です。MITM対策有りの設定で、かつIO CapabilitiesをNoInputNoOutputに設定することでも可能です。どう実装するのかはAIにまかせて、細かい仕様はださないことにします。

以下の通りプロンプトに仕様を指示しました。

スマートフォン(Central)を相手に、ペアリングを要求するコードを生成したい。
仕様を以下に示すので、仕様を満たすプログラムコードを提示してほしい。
Jusw Worksを体験するコードである。

開発環境
Arduino IDE

目的
・ペアリングを必須とし、セキュアな通信で送信する

機能
1.Bluetooth LE
・アドバタイズ周期は100msとする
・アドバタイズ種別はADV_INDとする
・送信出力(TxPower)は日本国内で設定可能な最大値とする
・Complete Local Nameを設定する("M5Stack NUS Secure")
・GATT通信
 * Nordic UART Service(NUS)をサポートする
 * Tx Characteristicをサポートする
 * Rx Characteristicをサポートする
 * 1秒毎にカウントアップした値を、アスキー文字列+改行コードでTx CharacteristicからNotificationする

・セキュリティ機能
 * 接続後、Tx CharacteristicのNotificationをEnableした際に、Just Worksによるペアリングを要求する
 * ペアリング後、通信を暗号化する
 * 新規のセントラルから接続要求があった場合、すでに登録済みのペアリング情報は削除する

2.センサデータ取得
・電源投入直後から1秒毎にカウントアップする(0~65535のループ)

3.ディスプレイ表示
・カウント値をディスプレイにテキスト表示する

生成されたArduinoスケッチ(プログラムコード)に自身で手を加えたり、修正のためのプロンプトを指示を行い、最終的なArduinoスケッチは以下となりました。

/**
 * @file M5CoreS3_Secure_NUS_Counter.ino
 * @author Gemini
 * @brief M5CoreS3 SEでカウンター値を生成し、セキュアなBLE NUS通信で送信する
 * @version 1.4
 * @date 2025-09-04
 * @copyright Copyright (c) 2025
 *
 * @note
 * v1.4: 認証方式をLE Secure Connectionsでのボンディング(MITMなし)に変更。
 * v1.3: ペアリングが失敗した場合(拒否/タイムアウト)に、接続を切断する処理を追加。
 * v1.2: Wire.begin()をsetup()に追加し、リンクエラーを修正。
 * v1.1: M5Unifiedライブラリの依存関係に起因するコンパイルエラーを修正 (#include <Wire.h> を追加)。
 * Just Worksによるペアリングを体験するためのプログラムです。
 * センサーは使用しません。
 *
 * 機能:
 * 1. 1秒毎にカウンターをインクリメントする (0-65535でループ)。
 * 2. カウンター値をディスプレイに表示する。
 * 3. Just Worksによるペアリングで保護されたNordic UART Service(NUS)を実装する。
 * 4. 新規接続があるたびに古いペアリング情報を削除する。
 * 5. 1秒毎にカウンター値をNUSのTx CharacteristicからNotificationで送信する。
 */


#include <M5Unified.h>
#include <Wire.h> // M5Unifiedの依存関係のために必要
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <esp_bt.h>
#include <esp_gap_ble_api.h>


// --- Nordic UART Service (NUS) のUUID定義 ---
#define SERVICE_UUID           "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"


// --- グローバル変数 ---
BLEServer* pServer = nullptr;
BLECharacteristic* pTxCharacteristic = nullptr;
volatile bool deviceConnected = false;
volatile uint16_t g_connId = 0; // ★ 接続IDを保持する変数
unsigned long lastUpdateTime = 0;
uint16_t counter = 0;


/**
 * @brief BLEサーバーのイベント(接続/切断)を処理するコールバッククラス
 */
class MyServerCallbacks: public BLEServerCallbacks {
    // ★ 接続時にパラメータを受け取れるよう、onConnectのシグネチャを変更
    void onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) {
        deviceConnected = true;
        g_connId = param->connect.conn_id; // ★ 接続IDを保存
        M5.Display.println("- Central connected.");
        Serial.println("Central connected.");


        // --- 新しい接続があった場合、既存のペアリング情報をすべて削除 ---
        int bonded_device_count = esp_ble_get_bond_device_num();
        if (bonded_device_count > 0) {
            Serial.printf("Found %d bonded devices. Clearing...\n", bonded_device_count);
            esp_ble_bond_dev_t *bonded_devices = (esp_ble_bond_dev_t *)malloc(sizeof(esp_ble_bond_dev_t) * bonded_device_count);
            if (bonded_devices) {
                esp_ble_get_bond_device_list(&bonded_device_count, bonded_devices);
                for (int i = 0; i < bonded_device_count; i++) {
                    esp_ble_remove_bond_device(bonded_devices[i].bd_addr);
                    char bda_str[18];
                    sprintf(bda_str, "%02X:%02X:%02X:%02X:%02X:%02X",
                            bonded_devices[i].bd_addr[0], bonded_devices[i].bd_addr[1], bonded_devices[i].bd_addr[2],
                            bonded_devices[i].bd_addr[3], bonded_devices[i].bd_addr[4], bonded_devices[i].bd_addr[5]);
                    Serial.printf("  - Removed bond for: %s\n", bda_str);
                }
                free(bonded_devices);
            }
        }
    }


    void onDisconnect(BLEServer* pServer) {
        deviceConnected = false;
        M5.Display.fillScreen(BLACK);
        M5.Display.setCursor(0,0);
        M5.Display.println("- Central disconnected.");
        Serial.println("Central disconnected. Restarting advertising...");
        // 切断後、再度アドバタイズを開始して次の接続を待つ
        pServer->getAdvertising()->start();
    }
};


/**
 * @brief BLEセキュリティイベントを処理するコールバッククラス
 */
class MySecurityCallbacks : public BLESecurityCallbacks {
    // 認証が完了したときに呼び出される
    void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
        if (cmpl.success) {
            Serial.println("Authentication success!");
            M5.Display.println("Pairing Success!");
        } else {
            Serial.printf("Authentication failed, reason: 0x%x\n", cmpl.fail_reason);
            M5.Display.println("Pairing Failed!");
            // ★ 認証失敗時は接続を切断
            if (pServer->getConnectedCount() > 0) {
                pServer->disconnect(g_connId);
                Serial.println("Disconnected due to pairing failure.");
            }
        }
        delay(1000);
    }


    // --- Just Worksでは使用されないが、純粋仮想関数のため実装が必要 ---
    void onPassKeyNotify(uint32_t pass_key) {}
    uint32_t onPassKeyRequest() { return 0; }
    bool onConfirmPIN(uint32_t pass_key) { return true; }
    bool onSecurityRequest() { return true; }
};




/**
 * @brief 初期設定
 */
void setup() {
    M5.begin();
    Wire.begin(); // Wireライブラリを明示的に初期化し、リンクエラーを回避
    Serial.begin(115200);


    M5.Display.setTextSize(2);
    M5.Display.println("Secure Counter");


    // --- BLEの初期化 ---
    BLEDevice::init("M5Stack NUS Secure");
    
    // BLEサーバーを作成
    pServer = BLEDevice::createServer();
    pServer->setCallbacks(new MyServerCallbacks());


    // NUSサービスを作成
    BLEService *pService = pServer->createService(SERVICE_UUID);


    // Tx Characteristic (サーバーからクライアントへ通知)
    pTxCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID_TX,
        BLECharacteristic::PROPERTY_NOTIFY
    );
    pTxCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED);
    
    // CCCD (Client Characteristic Configuration Descriptor) にもセキュリティを設定
    BLE2902* p2902Descriptor = new BLE2902();
    p2902Descriptor->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED | ESP_GATT_PERM_READ_ENCRYPTED);
    pTxCharacteristic->addDescriptor(p2902Descriptor);




    // Rx Characteristic (クライアントからサーバーへ書き込み)
    BLECharacteristic *pRxCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID_RX,
        BLECharacteristic::PROPERTY_WRITE
    );
    pRxCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED);


    // サービスを開始
    pService->start();


    // --- セキュリティ設定 ---
    BLESecurity *pSecurity = new BLESecurity();
    // ★ Secure Connectionsでのボンディング(MITMなし)を要求
    pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_BOND); 
    // ★ I/O能力なしに設定
    pSecurity->setCapability(ESP_IO_CAP_NONE); 
    pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
    BLEDevice::setSecurityCallbacks(new MySecurityCallbacks());


    // --- アドバタイズ設定 ---
    BLEAdvertising *pAdvertising = pServer->getAdvertising();
    pAdvertising->addServiceUUID(SERVICE_UUID);
    pAdvertising->setMinInterval(160); // 100ms (0.625ms単位)
    pAdvertising->setMaxInterval(160); // 100ms
    esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_P9); // 送信電力を最大(+9dBm)に設定


    // アドバタイズを開始
    pAdvertising->start();
    M5.Display.println("BLE advertising...");
    Serial.println("Waiting for a client connection to notify...");


    delay(1500);
    M5.Display.fillScreen(BLACK);
}


/**
 * @brief メインループ
 */
void loop() {
    if (millis() - lastUpdateTime > 1000) {
        lastUpdateTime = millis();
        
        counter++; // カウンターをインクリメント (65535を超えると自動で0に戻る)


        // --- ディスプレイ表示 ---
        M5.Display.fillScreen(BLACK);
        M5.Display.setCursor(0, 40);
        M5.Display.setTextSize(3);
        M5.Display.setTextColor(WHITE);
        M5.Display.println("Counter Value:");
        M5.Display.setTextSize(5);
        M5.Display.setTextColor(YELLOW);
        M5.Display.printf("%u", counter);
        
        Serial.printf("Counter: %u\n", counter);


        // --- BLE Notification送信 ---
        if (deviceConnected) {
            char buffer[10];
            snprintf(buffer, sizeof(buffer), "%u\n", counter);
            pTxCharacteristic->setValue((uint8_t*)buffer, strlen(buffer));
            pTxCharacteristic->notify();
            Serial.printf("Sent notification: %s", buffer);
        }
    }
    M5.update();
}

では、ペアリングの要点を確認します。Just Worksを実現するための設定箇所、パラメーターは以下の箇所となります。

pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_BOND);
  • ESP_LE_AUTH_REQ_SC_BOND(0b1001)
    • Bit3: 1 ⇒ LE Secure Connectionで認証する
    • Bit2: 0 ⇒ MITM(中間者攻撃)対策しない(この設定でJust Worksが選択される)
    • Bit0: 1 ⇒ ペアリング(ボンディング)する
pSecurity->setCapability(ESP_IO_CAP_NONE);
  • ESP_IO_CAP_NONE(0x03)⇒ NoInputNoOutput

それでは、実際にiPhoneから接続してペアリングをしてみます。今回は、接続時にペアリングが求められるのではなく、接続後キャラクタリスティックの操作時に要求されるようにしています。

iPhone上のペアリング要求(JustWorks)
動画:JustWorksの動き

いいですね。Just Worksによるペアリングができました!

イヤホンなど、入出力機能がないデバイスのほとんどがJust Worksによるペアリングになります。

「MITM対策無し」と言われるとピンと来ないかもしれませんが、例えばイヤホンとペアリングする際「本当に自分のイヤホンかな?」と不安になりませんか?人が大勢いる空間や、似たような製品を周りが使っている場所だと自信が持てなくなりますよね。本当にペアリング相手が正しい相手なのか、なりすましによる中間者攻撃ではないのか、不安が残るのがJust Worksという認証方式になります。

Just Worksの場合、正しい相手か見分けるための材料が「デバイス名」しかありません。ですので、各メーカーはデバイス名に、ランダム値や製品のシリアルIDなどユニークな値を入れることで名前の重複を避け、ユーザーがデバイスを認識しやすいように工夫したりします。

実践!Passkey Entry

次に、Passkey Entryを体験してみましょう。

スマートフォン(KeyboardDisplay)を相手にする場合、MITM対策有りの設定で、IO CapabilitiesをDisplayOnly に設定します。

スマートフォン相手にPasskey Entryとなる組み合わせ(赤線囲み)

以下の通りプロンプトに仕様を指示しました。DisplayOnly に設定することはわかっていますが、AIへの仕様にはあえてかかず、Passkey Entryによる認証がしたいということだけを伝えることにします。

スマートフォン(Central)を相手に、ペアリングを要求するコードを生成したい。
仕様を以下に示すので、仕様を満たすプログラムコードを提示してほしい。
Passkey Entryを体験するコードである。


開発環境
Arduino IDE

目的
・ペアリングを必須とし、セキュアな通信で送信する

機能
1.Bluetooth LE
・アドバタイズ周期は100msとする
・アドバタイズ種別はADV_INDとする
・送信出力(TxPower)は日本国内で設定可能な最大値とする
・Complete Local Nameを設定する("M5Stack NUS Secure")
・GATT通信
 * Nordic UART Service(NUS)をサポートする
 * Tx Characteristicをサポートする
 * Rx Characteristicをサポートする
 * 1秒毎にカウントアップした値を、アスキー文字列+改行コードでTx CharacteristicからNotificationする

・セキュリティ機能
 * 接続後、Tx CharacteristicのNotificationをEnableした際に、Passkey Entryによるペアリングを要求する
 * ペアリング後、通信を暗号化する
 * 新規のセントラルから接続要求があった場合、すでに登録済みのペアリング情報は削除する

・ペアリングを拒否、もしくはペアリング要求がタイムアウトした場合は接続を切断する

2.センサデータ取得
・電源投入直後から1秒毎にカウントアップする(0~65535のループ)


3.ディスプレイ表示
・カウント値をディスプレイにテキスト表示する
・ペアリング要求時、6桁のランダムなPasskeyを表示する(ペアリング要求の度にランダムな値とする)
・ペアリング成功、失敗にかかわらずカウント値の表示に戻す

生成されたArduinoスケッチ(プログラムコード)は以下となりました。先ほどのJust Works向けコードを改変したようで、特に修正点はありませんでした。

/**
 * @file M5CoreS3_Secure_NUS_Counter.ino
 * @author Gemini
 * @brief M5CoreS3 SEでカウンター値を生成し、セキュアなBLE NUS通信で送信する
 * @version 2.0
 * @date 2025-09-04
 * @copyright Copyright (c) 2025
 *
 * @note
 * v2.0: 認証方式をJust WorksからPasskey Entryに変更。
 * v1.4: 認証方式をLE Secure Connectionsでのボンディング(MITMなし)に変更。
 * v1.3: ペアリングが失敗した場合(拒否/タイムアウト)に、接続を切断する処理を追加。
 * Passkey Entryによるペアリングを体験するためのプログラムです。
 * センサーは使用しません。
 *
 * 機能:
 * 1. 1秒毎にカウンターをインクリメントする (0-65535でループ)。
 * 2. カウンター値をディスプレイに表示する。ペアリング中はパスキーを表示する。
 * 3. Passkey Entryによるペアリングで保護されたNordic UART Service(NUS)を実装する。
 * 4. 新規接続があるたびに古いペアリング情報を削除する。
 * 5. 1秒毎にカウンター値をNUSのTx CharacteristicからNotificationで送信する。
 */


#include <M5Unified.h>
#include <Wire.h> // M5Unifiedの依存関係のために必要
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <esp_bt.h>
#include <esp_gap_ble_api.h>


// --- Nordic UART Service (NUS) のUUID定義 ---
#define SERVICE_UUID           "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"


// --- グローバル変数 ---
BLEServer* pServer = nullptr;
BLECharacteristic* pTxCharacteristic = nullptr;
volatile bool deviceConnected = false;
volatile bool isPairingInProgress = false; // ペアリング処理中の状態を管理
volatile bool forceDisplayUpdate = false;  // 表示の強制更新を指示するフラグ
volatile uint16_t g_connId = 0;
unsigned long lastUpdateTime = 0;
uint16_t counter = 0;


/**
 * @brief BLEサーバーのイベント(接続/切断)を処理するコールバッククラス
 */
class MyServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) {
        deviceConnected = true;
        g_connId = param->connect.conn_id;
        Serial.println("Central connected.");


        // --- 新しい接続があった場合、既存のペアリング情報をすべて削除 ---
        int bonded_device_count = esp_ble_get_bond_device_num();
        if (bonded_device_count > 0) {
            Serial.printf("Found %d bonded devices. Clearing...\n", bonded_device_count);
            esp_ble_bond_dev_t *bonded_devices = (esp_ble_bond_dev_t *)malloc(sizeof(esp_ble_bond_dev_t) * bonded_device_count);
            if (bonded_devices) {
                esp_ble_get_bond_device_list(&bonded_device_count, bonded_devices);
                for (int i = 0; i < bonded_device_count; i++) {
                    esp_ble_remove_bond_device(bonded_devices[i].bd_addr);
                    char bda_str[18];
                    sprintf(bda_str, "%02X:%02X:%02X:%02X:%02X:%02X",
                            bonded_devices[i].bd_addr[0], bonded_devices[i].bd_addr[1], bonded_devices[i].bd_addr[2],
                            bonded_devices[i].bd_addr[3], bonded_devices[i].bd_addr[4], bonded_devices[i].bd_addr[5]);
                    Serial.printf("  - Removed bond for: %s\n", bda_str);
                }
                free(bonded_devices);
            }
        }
    }


    void onDisconnect(BLEServer* pServer) {
        deviceConnected = false;
        isPairingInProgress = false; // 切断時はペアリング状態をリセット
        forceDisplayUpdate = true;   // loopに表示更新を強制
        Serial.println("Central disconnected. Restarting advertising...");
        pServer->getAdvertising()->start();
    }
};


/**
 * @brief BLEセキュリティイベントを処理するコールバッククラス
 */
class MySecurityCallbacks : public BLESecurityCallbacks {
    // 認証が完了したときに呼び出される
    void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
        isPairingInProgress = false; // 認証完了でペアリング状態をリセット
        forceDisplayUpdate = true;   // loopに表示更新を強制


        if (cmpl.success) {
            Serial.println("Authentication success!");
        } else {
            Serial.printf("Authentication failed, reason: 0x%x\n", cmpl.fail_reason);
            if (pServer->getConnectedCount() > 0) {
                pServer->disconnect(g_connId);
                Serial.println("Disconnected due to pairing failure.");
            }
        }
    }


    // Passkey Entryでパスキーが生成されたときに呼び出される
    void onPassKeyNotify(uint32_t pass_key) {
        isPairingInProgress = true; // ペアリング処理を開始
        Serial.printf("Passkey notify: %06u\n", pass_key);
        
        // --- ディスプレイにパスキーを表示 ---
        M5.Display.fillScreen(BLACK);
        M5.Display.setCursor(0, 40);
        M5.Display.setTextSize(3);
        M5.Display.setTextColor(WHITE);
        M5.Display.println("Enter Passkey:");
        M5.Display.setTextSize(5);
        M5.Display.setTextColor(CYAN);
        M5.Display.printf("%06u", pass_key);
    }


    // --- Passkey Entry (表示のみ) では使用されないが実装が必要 ---
    uint32_t onPassKeyRequest() { return 0; }
    bool onConfirmPIN(uint32_t pass_key) { return true; }
    bool onSecurityRequest() { return true; }
};




/**
 * @brief 初期設定
 */
void setup() {
    M5.begin();
    Wire.begin();
    Serial.begin(115200);


    M5.Display.setTextSize(2);
    M5.Display.println("Secure Counter");


    // --- BLEの初期化 ---
    BLEDevice::init("M5Stack NUS Secure");
    
    pServer = BLEDevice::createServer();
    pServer->setCallbacks(new MyServerCallbacks());


    BLEService *pService = pServer->createService(SERVICE_UUID);


    pTxCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID_TX,
        BLECharacteristic::PROPERTY_NOTIFY
    );
    pTxCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED);
    
    BLE2902* p2902Descriptor = new BLE2902();
    p2902Descriptor->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED | ESP_GATT_PERM_READ_ENCRYPTED);
    pTxCharacteristic->addDescriptor(p2902Descriptor);


    BLECharacteristic *pRxCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID_RX,
        BLECharacteristic::PROPERTY_WRITE
    );
    pRxCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED);


    pService->start();


    // --- セキュリティ設定 ---
    BLESecurity *pSecurity = new BLESecurity();
    // ★ Passkey Entryによるペアリング(ボンディングあり, MITM耐性あり)を要求
    pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND); 
    // ★ I/O能力を表示のみに設定
    pSecurity->setCapability(ESP_IO_CAP_OUT); 
    pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
    BLEDevice::setSecurityCallbacks(new MySecurityCallbacks());


    // --- アドバタイズ設定 ---
    BLEAdvertising *pAdvertising = pServer->getAdvertising();
    pAdvertising->addServiceUUID(SERVICE_UUID);
    pAdvertising->setMinInterval(160); // 100ms
    pAdvertising->setMaxInterval(160); // 100ms
    esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_P9);


    pAdvertising->start();
    M5.Display.println("BLE advertising...");
    Serial.println("Waiting for a client connection to notify...");


    delay(1500);
    forceDisplayUpdate = true; // 初回描画を強制
}


/**
 * @brief メインループ
 */
void loop() {
    bool periodicUpdate = false;
    
    // 1秒ごとの定期更新を確認
    if (millis() - lastUpdateTime > 1000) {
        lastUpdateTime = millis();
        counter++;
        periodicUpdate = true;
    }


    // ペアリング処理中でない場合のみ、表示と通知のロジックを実行
    if (!isPairingInProgress) {
        // 定期更新または強制更新フラグが立った場合に画面を再描画
        if (periodicUpdate || forceDisplayUpdate) {
            if (forceDisplayUpdate) {
                forceDisplayUpdate = false; // フラグを消費
            }


            // --- ディスプレイ表示 ---
            M5.Display.fillScreen(BLACK);
            M5.Display.setCursor(0, 40);
            M5.Display.setTextSize(3);
            M5.Display.setTextColor(WHITE);
            M5.Display.println("Counter Value:");
            M5.Display.setTextSize(5);
            M5.Display.setTextColor(YELLOW);
            M5.Display.printf("%u", counter);
            
            Serial.printf("Counter: %u\n", counter);


            // --- BLE Notification送信 (接続中の定期更新時のみ) ---
            if (deviceConnected && periodicUpdate) {
                char buffer[10];
                snprintf(buffer, sizeof(buffer), "%u\n", counter);
                pTxCharacteristic->setValue((uint8_t*)buffer, strlen(buffer));
                pTxCharacteristic->notify();
                Serial.printf("Sent notification: %s", buffer);
            }
        }
    }
    M5.update();
}

それでは、ペアリングの要点を確認します。Passkey Entryを実現するための設定箇所、パラメーターは以下の箇所になります。

pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND);
  • ESP_LE_AUTH_REQ_SC_MITM_BOND(0b1101)
    • Bit3: 1 ⇒ LE Secure Connectionで認証する
    • Bit2: 1 ⇒ MITM(中間者攻撃)対策する
    • Bit0: 1 ⇒ ペアリング(ボンディング)する
pSecurity->setCapability(ESP_IO_CAP_OUT);
  • ESP_IO_CAP_OUT(0x00)⇒ DisplayOnly

指示した仕様に明記しなくても、DisplayOnlyに設定されていましたね。

それでは、実際にiPhoneから接続してペアリングをしてみます。Just Worksの際と同様に、接続した後、キャラクタリスティックの操作時にペアリング要求されるようにしています。

動画:Passkey Entryの動き

Passkey Entryによる認証方式が確認できました!

パスキーは0〜9の数字6桁で構成されるため、10^6=1,000,000通りとなります。そのため中間者の攻撃に強い、という仕組みです。

市場にあるBluetooth LE製品の中には、デバイス本体のラベルや同梱の用紙に固定されたパスキーが印字されているものが多く見受けられます。今回は、あえて固定値ではなく、ペアリング要求ごとにランダムなパスキーを生成するようにしましたが上手くいきました。固定値だと、覗き見や撮影によって覚えられてしまうと中間者攻撃対策の意味がなくなってしまいます。毎回ランダム値を表示して、都度その画面をBluetoothの通信範囲で見ながら入力することが、セキュリティを確保することにつながります。

実践!Numeric Comparison

最後に、Numeric Comparisonを体験してみましょう。

スマートフォン(KeyboardDisplay)を相手にする場合、MITM対策有りの設定で、IO CapabilitiesをDisplayYesNo に設定します。

スマートフォン相手にNumeric Comparisonとなる組み合わせ(赤線囲み)

以下の通りプロンプトに仕様を指示しました。

今回も、IO CapabilitiesをDisplayYesNoにするようには明記はしていません。はたしてAIは正しく理解してくれるでしょうか?

スマートフォン(Central)を相手に、ペアリングを要求するコードを生成したい。
仕様を以下に示すので、仕様を満たすプログラムコードを提示してほしい。
Numeric Comparisonを体験するコードである。


開発環境
Arduino IDE

目的
・ペアリングを必須とし、セキュアな通信で送信する

機能
1.Bluetooth LE
・アドバタイズ周期は100msとする
・アドバタイズ種別はADV_INDとする
・送信出力(TxPower)は日本国内で設定可能な最大値とする
・Complete Local Nameを設定する("M5Stack NUS Secure")
・GATT通信
 * Nordic UART Service(NUS)をサポートする
 * Tx Characteristicをサポートする
 * Rx Characteristicをサポートする
 * 1秒毎にカウントアップした値を、アスキー文字列+改行コードでTx CharacteristicからNotificationする

・セキュリティ機能
 * 接続後、Tx CharacteristicのNotificationをEnableした際に、Numeric Comparisonによるペアリングを要求する
 * ペアリング後、通信を暗号化する
 * 新規のセントラルから接続要求があった場合、すでに登録済みのペアリング情報は削除する

・ペアリングを拒否、もしくはペアリング要求がタイムアウトした場合は接続を切断する

2.センサデータ取得
・電源投入直後から1秒毎にカウントアップする(0~65535のループ)


3.ディスプレイ表示
・カウント値をディスプレイにテキスト表示する
・ペアリング要求時、6桁のランダムな認証キーを表示する(ペアリング要求の度にランダムな値とする)
・ペアリング成功、失敗にかかわらずカウント値の表示に戻す

4.YesNoボタン
・M5Stack用デュアルボタンをPort.B(GPIO)コネクタに接続する
https://docs.m5stack.com/ja/unit/dual_button
・Numeric Comparisonによるペアリング実施の際、RedボタンをYes、BlueボタンをNoとする
・ペアリング実施以外ではボタンは表示しない

生成されたArduinoスケッチ(プログラムコード)は以下のとおりです。デュアルボタンユニットの制御も含め、一発で生成してきました。

/**
 * @file M5CoreS3_Secure_NUS_Counter.ino
 * @author Gemini
 * @brief M5CoreS3 SEでカウンター値を生成し、セキュアなBLE NUS通信で送信する
 * @version 3.0
 * @date 2025-09-04
 * @copyright Copyright (c) 2025
 *
 * @note
 * v3.0: Numeric Comparisonに対応。デュアルボタンユニットでボタンを再現。
 * Numeric Comparisonによるペアリングを体験するためのプログラムです。
 * センサーは使用しません。
 *
 * 機能:
 * 1. 1秒毎にカウンターをインクリメントする (0-65535でループ)。
 * 2. カウンター値をディスプレイに表示する。ペアリング中は認証キーを表示する。
 * 3. Numeric Comparisonによるペアリングで保護されたNordic UART Service(NUS)を実装する。
 * 4. 新規接続があるたびに古いペアリング情報を削除する。
 * 5. 1秒毎にカウンター値をNUSのTx CharacteristicからNotificationで送信する。
 * 6. Port BのデュアルボタンをペアリングのYes/No入力として使用する。
 */


#include <M5Unified.h>
#include <Wire.h> // M5Unifiedの依存関係のために必要
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <esp_bt.h>
#include <esp_gap_ble_api.h>


// --- Nordic UART Service (NUS) のUUID定義 ---
#define SERVICE_UUID           "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"


// --- デュアルボタンのピン定義 (Port B) ---
#define RED_BTN_PIN  9  // Yes
#define BLUE_BTN_PIN 10 // No


// --- グローバル変数 ---
BLEServer* pServer = nullptr;
BLECharacteristic* pTxCharacteristic = nullptr;
volatile bool deviceConnected = false;
volatile bool isPairingInProgress = false; // ペアリング処理中の状態を管理
volatile bool forceDisplayUpdate = false;  // 表示の強制更新を指示するフラグ
volatile uint16_t g_connId = 0;
unsigned long lastUpdateTime = 0;
uint16_t counter = 0;


/**
 * @brief BLEサーバーのイベント(接続/切断)を処理するコールバッククラス
 */
class MyServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) {
        deviceConnected = true;
        g_connId = param->connect.conn_id;
        Serial.println("Central connected.");


        // --- 新しい接続があった場合、既存のペアリング情報をすべて削除 ---
        int bonded_device_count = esp_ble_get_bond_device_num();
        if (bonded_device_count > 0) {
            Serial.printf("Found %d bonded devices. Clearing...\n", bonded_device_count);
            esp_ble_bond_dev_t *bonded_devices = (esp_ble_bond_dev_t *)malloc(sizeof(esp_ble_bond_dev_t) * bonded_device_count);
            if (bonded_devices) {
                esp_ble_get_bond_device_list(&bonded_device_count, bonded_devices);
                for (int i = 0; i < bonded_device_count; i++) {
                    esp_ble_remove_bond_device(bonded_devices[i].bd_addr);
                }
                free(bonded_devices);
            }
        }
    }


    void onDisconnect(BLEServer* pServer) {
        deviceConnected = false;
        isPairingInProgress = false; // 切断時はペアリング状態をリセット
        forceDisplayUpdate = true;   // loopに表示更新を強制
        Serial.println("Central disconnected. Restarting advertising...");
        pServer->getAdvertising()->start();
    }
};


/**
 * @brief BLEセキュリティイベントを処理するコールバッククラス
 */
class MySecurityCallbacks : public BLESecurityCallbacks {
    // 認証が完了したときに呼び出される
    void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) {
        isPairingInProgress = false; // 認証完了でペアリング状態をリセット
        forceDisplayUpdate = true;   // loopに表示更新を強制


        if (cmpl.success) {
            Serial.println("Authentication success!");
        } else {
            Serial.printf("Authentication failed, reason: 0x%x\n", cmpl.fail_reason);
            if (pServer->getConnectedCount() > 0) {
                pServer->disconnect(g_connId);
                Serial.println("Disconnected due to pairing failure.");
            }
        }
    }
    
    // Numeric Comparisonで認証キーの確認が要求されたときに呼び出される
    bool onConfirmPIN(uint32_t pass_key) {
        isPairingInProgress = true;
        Serial.printf("Confirm PIN: %06u\n", pass_key);


        // --- ディスプレイに認証キーと操作方法を表示 ---
        M5.Display.fillScreen(BLACK);
        M5.Display.setCursor(0, 20);
        M5.Display.setTextSize(3);
        M5.Display.setTextColor(WHITE);
        M5.Display.println("Confirm Passkey:");
        M5.Display.setTextSize(5);
        M5.Display.setTextColor(CYAN);
        M5.Display.printf("%06u", pass_key);
        M5.Display.setTextSize(2);
        M5.Display.setCursor(20, 200);
        M5.Display.setTextColor(RED);
        M5.Display.print("Red: YES");
        M5.Display.setCursor(190, 200);
        M5.Display.setTextColor(BLUE);
        M5.Display.println("Blue: NO");
        
        unsigned long startTime = millis();
        while (millis() - startTime < 30000) { // 30秒のタイムアウト
            // 赤ボタン(YES)が押されたか確認
            if (digitalRead(RED_BTN_PIN) == LOW) {
                Serial.println("YES confirmed by user.");
                delay(200); // 簡単なチャタリング防止
                return true; // ペアリングを承認
            }
            // 青ボタン(NO)が押されたか確認
            if (digitalRead(BLUE_BTN_PIN) == LOW) {
                Serial.println("NO confirmed by user.");
                delay(200); // 簡単なチャタリング防止
                return false; // ペアリングを拒否
            }
            delay(50);
        }


        Serial.println("Confirmation timed out.");
        return false; // タイムアウト時はペアリングを拒否
    }


    // --- 他の認証方式では使用されないが実装が必要 ---
    void onPassKeyNotify(uint32_t pass_key) {}
    uint32_t onPassKeyRequest() { return 0; }
    bool onSecurityRequest() { return true; }
};




/**
 * @brief 初期設定
 */
void setup() {
    M5.begin();
    Wire.begin();
    Serial.begin(115200);


    // --- デュアルボタンのピンを初期化 ---
    pinMode(RED_BTN_PIN, INPUT_PULLUP);
    pinMode(BLUE_BTN_PIN, INPUT_PULLUP);


    M5.Display.setTextSize(2);
    M5.Display.println("Secure Counter");


    // --- BLEの初期化 ---
    BLEDevice::init("M5Stack NUS Secure");
    
    pServer = BLEDevice::createServer();
    pServer->setCallbacks(new MyServerCallbacks());


    BLEService *pService = pServer->createService(SERVICE_UUID);


    pTxCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID_TX,
        BLECharacteristic::PROPERTY_NOTIFY
    );
    pTxCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED);
    
    BLE2902* p2902Descriptor = new BLE2902();
    p2902Descriptor->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED | ESP_GATT_PERM_READ_ENCRYPTED);
    pTxCharacteristic->addDescriptor(p2902Descriptor);


    BLECharacteristic *pRxCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID_RX,
        BLECharacteristic::PROPERTY_WRITE
    );
    pRxCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENCRYPTED);


    pService->start();


    // --- セキュリティ設定 ---
    BLESecurity *pSecurity = new BLESecurity();
    pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND); 
    pSecurity->setCapability(ESP_IO_CAP_IO); 
    pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);
    BLEDevice::setSecurityCallbacks(new MySecurityCallbacks());


    // --- アドバタイズ設定 ---
    BLEAdvertising *pAdvertising = pServer->getAdvertising();
    pAdvertising->addServiceUUID(SERVICE_UUID);
    pAdvertising->setMinInterval(160); // 100ms
    pAdvertising->setMaxInterval(160); // 100ms
    esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_P9);


    pAdvertising->start();
    M5.Display.println("BLE advertising...");
    Serial.println("Waiting for a client connection to notify...");


    delay(1500);
    forceDisplayUpdate = true; // 初回描画を強制
}


/**
 * @brief メインループ
 */
void loop() {
    M5.update();


    // ペアリング処理中でない場合のみ、通常の処理を行う
    if (!isPairingInProgress) {
        bool periodicUpdate = false;
        
        if (millis() - lastUpdateTime > 1000) {
            lastUpdateTime = millis();
            counter++;
            periodicUpdate = true;
        }


        if (periodicUpdate || forceDisplayUpdate) {
            if (forceDisplayUpdate) {
                forceDisplayUpdate = false;
            }


            // ディスプレイ表示
            M5.Display.fillScreen(BLACK);
            M5.Display.setCursor(0, 40);
            M5.Display.setTextSize(3);
            M5.Display.setTextColor(WHITE);
            M5.Display.println("Counter Value:");
            M5.Display.setTextSize(5);
            M5.Display.setTextColor(YELLOW);
            M5.Display.printf("%u", counter);
            
            Serial.printf("Counter: %u\n", counter);


            // BLE Notification送信
            if (deviceConnected && periodicUpdate) {
                char buffer[10];
                snprintf(buffer, sizeof(buffer), "%u\n", counter);
                pTxCharacteristic->setValue((uint8_t*)buffer, strlen(buffer));
                pTxCharacteristic->notify();
                Serial.printf("Sent notification: %s", buffer);
            }
        }
    }
}

デュアルボタンユニットの制御の分、コードが少し長くなりました。

では、ペアリングの要点を確認します。Numeric Comparisonを実現するための設定箇所、パラメーターは以下の箇所になります。

pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND);
  • ESP_LE_AUTH_REQ_SC_MITM_BOND(0b1101)
    • Bit3: 1 ⇒ LE Secure Connectionで認証する
    • Bit2: 1 ⇒ MITM(中間者攻撃)対策する
    • Bit0: 1 ⇒ ペアリング(ボンディング)する
pSecurity->setCapability(ESP_IO_CAP_IO);
  • ESP_IO_CAP_IO(0x01)⇒ DisplayYesNo

それでは、実際にiPhoneから接続してペアリングをしてみます。前の2つの方式と同様に、接続後のキャラクタリスティック操作時にペアリング要求されるようにしています。

iPhone上のペアリング要求(Numeric Comparison)
動画:Numeric Comparisonの動き

いいですね、Numeric Comparisonの動作も確認することができました。

お互い入出力機能を持っているリッチなデバイス同士ではNumeric Comparisonによる認証方式が選択されることが多いです。

この認証方式では、Bluetooth通信範囲内で両デバイスのディスプレイを確認しながら相互にボタン操作や入力を行うため、中間者によるなりすましの余地がありません。

まとめと反省

今回は「ペアリング」をテーマに、IO Capabilitiesの組み合わせによる各認証方式を試すことができました。

ディスプレイの有無などデバイスごとの事情もあるので、常に自由に認証方式を選べるわけではないと思いますが、違いを知ることが出来たのではないでしょうか。

イヤホンのように特定のデバイスとの紐付けを前提とするのであればペアリングは有効ですが、センサーデータの暗号化のみを目的とする場合はオススメできません。接続手順が増えるので敬遠されますし、不特定多数との接続を前提とするシステムにはペアリングは不向きです。また、開発時のトラブルになりがちでもあります。全体を見据えてペアリングの有無も含めてシステム設計ができるといいですね。

今回、本当はBluetooth Classicのペアリングについてもお伝えしようとおもっていたのですが、紹介程度に留めようと思います。

Bluetooth Classicのペアリングには、Legacy Pairingと呼ばれる4桁のPINコード認証と、SSP(Secure Simple Pairing)という、Just Works、Passkey Entry、Numeric Comparisonの認証方式があります。Bluetooth ClassicとBluetooth LEは、接続や通信方式はまったく異なりますが、SSPのそれぞれの認証方式は今回体験した内容と同等ですので、イメージはつかめると思います。

なぜBluetooth Classicのペアリングについて紹介だけに留まってしまったかというと、M5Stack CoreS3 SEに搭載のESP32が、Bluetooth Classicに非対応ということがわかったからです。執筆当時、AIが「Bluetooth Classicにも対応しています」と回答してくれたので、すっかり信じ切っていつまでもコンパイルが通らないコードを四苦八苦しながらトライしていました。

言い訳になってしまいますが判断が遅れた理由として、冒頭にもお伝えしたとおり、M5Stack自体はBluetooth認証を取得していない為、M5Stackのドキュメント関連すべてにBluetoothというワードがでてこないことです。ですので、私もすぐにBluetooth Classicに非対応であることを確認できませんでしたし、AIも学習できず誤った回答をしたのだろうと推測します。

では実際にどうやって確かめたかというと、まずはCoreS3-SE のSoCの型番を確認してESP32-S3であることを突き止めました。次にESP32-S3 Series のデータシートを見てBluetoothの機能にBluetooth Classicが記載されていないことを確認しました。

ESP32-S3はBluetooth LEのみ

AIの情報を鵜呑みにすることの危険性を毎回お伝えしてきましたが、私自身がその落とし穴にはまってしまいました。この反省を踏まえ、今後のために記録しておきます。

繰り返しになりますが、M5Stackで製品を開発しようとすると、最後のBluetooth認証取得の際に困難が待ち構えています。M5Stackを用いた製品化をご検討の際は、この点に十分ご注意ください。

次回は何かアイデアが浮かんだらトライしてみようと思います。何か良いお題があればご連絡ください!

よろしければシェアをお願いします
目次