はじめに
この記事はSwitchBot顔認証Padの認証イベントでWebHookを投げるための解析内容についてです。
解析内容に基づいてXIAO ESP32-C6のBluetoothで顔認証Padからの認証イベントを受けてWiFi経由でWebHookを投げる仕組みを作成しています。
うまく応用すると顔認証でPCにloginするとか何か面白い応用ができるかもしれません。
経緯
私の自宅の玄関は20年物の電気錠(リモコンで解錠するタイプ)です。
最近流行りの顔認証で解錠できるようにできないかと考えていた時にSwitchBotの顔認証Padのニュースが目に入りました。SwitchBot SmartLockのオプションなのでSmartLockと一緒でないと使えなさそうです。
ただ既に電気錠を導入している場合
- 顔認証Padだけでいい、スマートロック部分は無駄
- 内側とはいえ玄関に余計なものを付けたくない
- 電気錠で操作したとき結構な勢いで回るのでスマートロックが壊れたり外れたりしそう
と色々考え躊躇していました。
GeminiやClaude Opusと相談したところ、SwitchBotならハブ経由で顔認証のイベントを取れるんじゃないかと提案してきたので、(ウソでした)試しに顔認証Padとハブミニを買ってみました。
色々設定を確認してみましたが、イベントはスマートロック側の動作の結果を取得するしかなく、直接顔認証Padとハブだけから取れるのは電池残量のアラートくらいでした。
次に考えたのはスマートロックをバラして信号取り出せばいけるのでは?というわけでSmartLockLiteを買ってみました。
とりあえずバラす前に顔認証PadとSmartLockLiteとのBLEの通信をClaude Opusに解析してもらって、SmartLockの代わりをするものをESP32で作れないか聞いたところ、できそうとのこと。
で色々試行錯誤してできたのがこれです。
結局顔認証PadだけでよかったのでハブミニとSmartLockLiteが余ってしまいました。
無駄な買い物をしてしまった。。。
この記事では、
- PadとLockのBLE通信プロトコルの解析
- ESP32-C6を偽Lockとして動かしてPadと接続させる
- 認証イベントを受けて任意のURLへHTTP POSTする
という部分を書いています。
コード一式はここに置いてあります。
電気錠との接続は 別途記事を書きます。 書きました。
⚠️ 本記事は自分が所有する機器を相互接続させるための解析です。
解析自体はClaudeOpusにやってもらってます。嘘が混じってる可能性はあります。
この記事をもとに試すなら自己責任でお願いします。
通信の全体像
ポイントは、Padは顔認証に成功すると、ペアリング済みのLockに対して自分からBLE接続して暗号化された解錠コマンドを送ってくるという挙動です。これを利用し、ESP32をLockとして登録しておけば、認証成功のたびにESP32へ接続が来る=それが認証イベントになります。
今回選んだデバイスはSeeed Studioの XIAO ESP32-C6。BLEとWiFiが載っていてアンテナも内蔵していて小さく、HTTP通知だけならこれ1個で完結します。
デバイスとBLEの基本構造
SwitchBotのデバイスは BLE GATT で通信します。
Padの主要なUUIDは以下の通り。
Lockも同じサービス/キャラクタリスティック構成です。
| 項目 | 値 |
|---|---|
| BLE Company ID |
0x0969 (SwitchBot) |
| Service UUID | cba20d00-224d-11e6-9fb8-0002a5d5c51b |
| Write Characteristic | cba20002-224d-11e6-9fb8-0002a5d5c51b |
| Notify Characteristic | cba20003-224d-11e6-9fb8-0002a5d5c51b |
通信は「Writeで要求 → Notifyで応答」の往復で進みます。
アドバタイズのManufacturer Data
SwitchBotデバイスはCompany ID 0x0969 のManufacturer Dataにステータスを載せて常時アドバタイズしています。Padの場合:
aa bb cc dd ee ff 9a 5c 00 00 00 00 00 32
|--- MAC (6B) ---| | | |
seq 固定(0x5c) リンク先デバイスタイプ
0x32=未リンク, 0x2d=Lock Lite
byte[6]: シーケンス番号(認証が起きると +3 ジャンプする)
byte[13]: リンク先のデバイス種別
この byte[6] が認証のたびにジャンプするので、最初は「アドバタイズを監視するだけで認証検知できるのでは?」と思いました。が、これだけでは「いつ・誰が・どの方式で」認証したかまではわかりません。やはり解錠コマンドの中身を見る必要があります。
暗号化プロトコルの解析
PadもLockも、GATTの中身は AES-128-CTR で暗号化されています。鍵はデバイスごとに異なります。
1 鍵の入手
肝心の暗号鍵ですが、これは SwitchBotのクラウドAPI(アプリのログイン認証情報経由)から、自分のデバイスの鍵を取得できます。
このプロジェクトでは make_config.py というスクリプトを用意して、SwitchBotアカウントでログイン → 対象PadのMACを指定 → 鍵ID(key_id)と16バイトの暗号鍵を取得して、src/config.h に書き出すようにしました。
| パラメータ | Pad |
|---|---|
| key_id |
0x75(PAD_KEY_ID) |
| enc_key | 16バイト(PAD_ENC_KEY) |
| アルゴリズム | AES-128-CTR |
2 セッション確立(IV交換)
CTRモードなのでIV(カウンタ初期値)が必要です。SwitchBotは通信開始時にデバイス側からIVを払い出す仕組みになっていました。
3 パケットフォーマット
| 方向 | フォーマット |
|---|---|
| 送信 (Write) |
57 + KEY_ID(1B) + IV[0:2](2B) + 暗号文
|
| 受信 (Notify) |
01 + KEY_ID(1B) + IV[0:2](2B) + 暗号文
|
| IV要求 |
57 00 00 00 0F 21 03 KEY_ID(平文) |
| IV応答 |
01 00 00 00 + IV(16B)(平文) |
先頭の 57(送信)/ 01(受信)はパケット種別、続く1バイトが鍵ID、次の2バイトがIVの先頭2バイト(受信側でどのIVを使ったか識別するため)です。
4 復号コード
Pythonで書くとこれだけです。鍵さえあればキャプチャした.pklgを全部復号できます。
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
# 暗号化 (送信)
cipher = Cipher(algorithms.AES128(key), modes.CTR(iv))
ciphertext = iv[0:2] + cipher.encryptor().update(plaintext)
packet = bytes([0x57]) + bytes([KEY_ID]) + ciphertext
# 復号 (受信): response = [01, KEY_ID, IV[0], IV[1], ...ciphertext...]
cipher = Cipher(algorithms.AES128(key), modes.CTR(iv))
plaintext = cipher.decryptor().update(response[4:])
ESP32側では esp_aes(mbedTLS)で同じことをやり、IVはNVSに永続化しています(後述する「IV不一致問題」のため)。
コマンドの定義
復号した平文を眺めると、コマンド体系が見えてきます。
| コマンド | 平文バイト列 | 説明 |
|---|---|---|
| SESSION_START | 0F 52 05 07 01 |
暗号セッション開始 |
| SESSION_END | 0F 52 05 07 02 |
暗号セッション終了 |
| POLL_STATUS | 0F 53 01 06 |
ステータスポーリング |
| LOCK_OP | 0F 4E ... |
施錠/解錠などの操作 |
| LOCK_INFO | 0F 4F 81 02 |
ステータス問い合わせ |
一番重要なのが Pad → Lock に飛ぶ LOCK_OP(0F 4E) です。8バイトの平文で、誰がどうやって認証したかが入っています。
0f 4e XX 03 AA BB CC DD
│ │ │ │ │ │ │ └ byte[7]: フラグ
│ │ │ │ │ │ └ byte[6]: 認証スロットID(登録した個人番号)
│ │ │ │ │ └ byte[5]: 0x80=unlock, 0x00=lock
│ │ │ │ └ byte[4]: 認証方式
│ │ │ └ byte[3]: 0x03 (固定)
│ │ └ byte[2]: 0x01 (固定)
│ └ byte[1]: 0x4e = LOCK_OP
└ byte[0]: 0x0f = コマンドプレフィックス
byte[4] の認証方式がうれしいポイントで、どの認証手段で開けたかが取れます:
| 値 | 方式 |
|---|---|
0x00 |
Lockボタン |
0x04 |
キーパッド(暗証番号) |
0x08 |
NFC |
0x0c |
指紋 |
0x18 |
顔認証 |
0x20 |
静脈認証 |
byte[6] のスロットIDは「誰が」を表すクレデンシャル番号です。これで「○○さんが顔認証で開けた」というイベントが手に入ります。ただし番号しか来ないのでSwitchBotアプリで顔認証Padに認証手段を登録するときに一緒にlogを確認しbyte[6] の値が誰なのかをメモしておいてWebHookを受けた側で人物名に変換する必要があります。
PadとLockのリンク手順を解析
ここまでで通信の中身は読めるようになりました。次は「ESP32をLockとしてPadに登録する」ために、iPhoneのSwitchBotアプリがPadとLockをペアリングするときの通信を解析します。iPhoneのPacketLoggerでキャプチャして復号しました。
リンクは3つのセッションで構成されていました。
1. iPhone → Pad(Lockの情報を通知)
1. IV取得 (PAD_KEY_ID=0x75)
2. デバイス情報取得
TX: 02 → RX: 5c 0c 18 01 02 00 0a 01 02 03 ...
3. 設定書込み
4. データブロック書込み(認証鍵らしきもの)
TX: 0f 20 04 6a 00 <KEY_BLOCK_0>
TX: 0f 20 04 6a 01 <KEY_BLOCK_1>
5. Lock MACアドレス設定 ★ 重要
TX: 06 01 6a 11 22 33 44 55 66
│ │ │ └── Lock MAC: 11:22:33:44:55:66
│ │ └── スロット番号
│ └── サブコマンド
└── コマンド
つまり、Padに対して「このMACのデバイスがLockだよ」と教え込むわけです。PadはここでもらったMACに接続します。
2. iPhone → Lock Lite
PadのことをLock側にも書き込んでいるようでした(Lock側の鍵は手元になく復号できませんでしたが、ESP32を偽Lockにする場合はこちらは自前実装するので問題ありません)。
3. iPhone → Pad(リンク後の確認)
特殊セッション開始 → ステータスポーリングを繰り返して安定化を待つ
TX: 0f 53 01 06
RX: 00 00 00 00 00 29 00 → 安定化待ち
RX: 00 00 00 00 00 01 00 → 安定
この解析結果をもとに、ESP32側に 「偽Lock登録モード」 を実装しました(register_lock.c)。ESP32をBLE GATT クライアントとして動かし、PadのMACへ接続 → 上記の9ステップ相当の登録手順を実行 → ESP32が生成した lock_key をPadに登録します。鍵はESP32側で生成してNVSに保存します。
実機では 起動後にGPIO9(BOOTボタン)を3秒長押しすると登録モードに入るようにしました。
偽Lock GATTサーバーを実装する
登録が済むと、Padは認証成功のたびにESP32へ接続してきます。今度はESP32を GATT サーバー として動かし、Lockらしく振る舞います。
認証のやり取り
実際のログを解析した結果、認証1回につき4回のWriteが来ることがわかりました。
1. connectable ADV を開始(名前="WoLL", MFRデータ=Lock Lite形式)
2. Padが顔認証成功 → BLE接続 → GATT Write
3. Write #1: IV要求 → IV応答(20バイト)
4. Write #2: 暗号化 LOCK_OP → 空ACK + STATUS PUSH
5. Write #3-4: 暗号化 LOCK_INFO → ステータス応答
- 応答のbyte[0]を 0x0f(処理中) → 0x10(完了) と返すとPadが緑ランプ点灯
6. 合計4 writes で認証完了
LOCK_OPを受け取った時点で「顔認証が成功した」とわかるので、ここで後述のHTTP通知を投げます。LOCK_INFOにはステータスを返してPadのUIを完了させます。
応答フォーマット
| コマンド | 応答 |
|---|---|
| IV要求 |
01 00 00 00 + IV(16B) = 20バイト |
| LOCK_OP | 空Ack: 01 KEY_ID IV[0] IV[1](4バイト) |
| LOCK_INFO |
01 KEY_ID IV[0] IV[1] + encrypt(7B) = 11バイト |
| STATUS PUSH |
0F KEY_ID IV[0] IV[1] + encrypt(7B) = 11バイト(自発送信) |
はまりどころ
ここが一番試行錯誤したところです。同じことをやる人のために残しておきます。
① PadはBLE接続先をOUIで検証する
Padは接続先MACのOUIを検証していて、SwitchBot Lockの実機が使うOUIでないと弾かれます。
esp_base_mac_addr_set() で起動直後にベースMACを 58:e6:c5:xx:xx:xx に上書きすることで接続されるようになりました。make_config.py では空欄入力でこのOUIのランダムMACを自動生成します。
② BLE接続までに数分かかる
デフォルトのアドバタイズ間隔(1.28秒)だとPadが見つけるまで数分かかります。itvl_min = itvl_max = 160(100ms)にしたら30秒以内に接続するようになりました。
③ IV不一致(Padが前回のIVをキャッシュ)
再起動するとIVがリセットされてPadと食い違うことがありました。IVをNVSに永続化して再利用することで解決しています。
④ Notifyの同一値が抑制される
BLEのNotifyは同じ値を連続送信すると重複排除されることがあるので、応答のbyte[0]を毎回ユニークな値(カウンタ)にしています。
認証イベントをHTTP POSTする
LOCK_OPを受信したら、解析した内容(認証方式・スロットID)をJSONにしてPOSTします。
BLEのコールバックをブロックしないよう、別タスクでHTTPを投げます。
static void http_post_task(void *arg)
{
post_args_t *a = (post_args_t *)arg;
char json[256];
snprintf(json, sizeof(json),
"{\"event\":\"auth\",\"count\":%lu,\"action\":\"%s\","
"\"method\":\"%s\",\"slot\":%d}",
(unsigned long)a->count, a->action, a->method, a->slot_id);
esp_http_client_config_t cfg = {
.url = NOTIFY_URL,
.method = HTTP_METHOD_POST,
.timeout_ms = 5000,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, json, strlen(json));
esp_http_client_perform(client);
esp_http_client_cleanup(client);
free(a);
vTaskDelete(NULL);
}
実際に飛ぶJSONはこんな感じ:
{ "event": "auth", "count": 42, "action": "unlock", "method": "face", "slot": 3 }
これを自宅のHome Assistantやスマートスピーカー連携、Discord通知などに繋げば、**「顔認証で○○する」**が自由に作れます。
なお、顔認証PadへのLOCK_INFO応答のステータスをHTTP GETで決める仕組みも入れていて、GETのbodyが open なら即完了、close なら未解錠として返すこともできます(電気錠の実状態と連動させたいとき用)。
まとめ
- SwitchBot顔認証Padは、認証成功時にペアリング済みLockへBLEで解錠コマンドを送る
- 通信は AES-128-CTR。鍵は自分のSwitchBotアカウントから取得
- 解錠コマンド(LOCK_OP)から 認証方式・登録ID まで取れる
SwitchBot顔認証Padからの認証イベントを受けてWebHookを投げる仕組みをXIAO ESP32-C6で実現しました。