スマホからESP32のWiFi設定をするためにMatterを実装する(コミッショニング編)

# はじめに

ESP32等の無線モジュールを使って簡単にインターネットにつながる機器を作れますが、WiFiに接続するためのSSIDとパスワードの設定方法が悩ましいです。

- ESP32内に書き込んでおく(ハードコーディング含む)
- soft-APモードを使ってブラウザ等から設定
- SmartConfigアプリを使う

ESP32を使う場合このどれかの方法を取る場合が多いと思いますが、どれも一長一短があります。SmartConfigは簡単で良いのですが、専用アプリを入れる必要があるのと、アプリ上でWiFiのパスワードを入力するのに少し抵抗があります。

### Matter

最近、[Matter](https://csa-iot.org/all-solutions/matter/)に対応したIoT機器が少しずつ増えてきていて、対応機器はAndroidやiOSのホームアプリからQRコードを読み込むだけでセットアップできるので便利です。自作の機器もスマートホームデバイスとして動作しなくとも、せめてWiFi設定だけでも簡単にできるとうれしいです。

# Matterを実装する

世の中にMatterのライブラリはありますが、今回は自分で実装してみます。

- ライブラリが大きすぎる： [esp-matter](https://github.com/espressif/esp-matter)は大きくて通常のパーティション構成では動かない
- ビルドが面倒: platform.ioで簡単にビルドしたい
- Matterについて理解を深めたい

と、軽い気持ちで実装してみたのですが、コミッショニングだけでもかなり面倒だったので備忘録として残しておきます。

:::note warn
お断り
- とりあえず動作させたいだけなので、多くの点で仕様の要求を満たしていません
- 製品を販売するなら機器の認証が必要なので、仕様で要求されていることを満たす必要があります
- メモリが足りないなどの事情がない限りconnectedhomeip等のSDKを使うのが無難です
:::

## ドキュメント

- [Matterの仕様書](https://csa-iot.org/developer-resource/specifications-download-request/)
- [Nature Engineering Blog](https://engineering.nature.global/) 日本語の情報が一番充実してそう
- [connectedhomeip](https://github.com/project-chip/connectedhomeip) 仕様書を読んでも良くわからない部分は実装を見るのが早い

仕様書のダウンロード時にメールアドレスと会社名と名前を要求されます。登録するとすぐにリンクがメールで届きました。ただ、検索すれば出てくるので最初は登録しなくて良いかもしれません。(例えば [これ](https://leconiot.com/matter/1.2/index.html) とか)

後述しますが、Matterの仕様は1.0ではなく最新版のほうが変な罠が少なくて良いです。

#### 仕様を見る前の想像

- BLE GATTのCharacteristicsなどを適切に用意すればWiFiの設定を書き込んでくれる？
- DACというデバイス認証の仕組みがあると聞いたけど、開発用証明書が使えるみたいだし簡単そう

#### 仕様を読み始めた

楕円曲線暗号を使ったSPAKE2+の鍵交換や、AES-ECC, ECDSやIPv6が必須っぽくて実装できる気がしないです。Matterのライブラリが巨大なのは仕方ないかも…

と、思ったのですが今時HTTPS通信必須だし、ESP32で使われているmbedTLSに必要なものがほとんど入っているので、大丈夫な気がします。IPv6はファームウェアサイズが大きくなるので悩ましいけど、PASEを使ったコミッショニングだけでCASEを使わないなら無くても動きそうです。

## 利用するライブラリ

ESP32標準のBLEライブラリはあまり評判が良くないのと、最近のESP-IDFにもNimBLEが含まれていてほぼ標準になっているので、NimBLEを使います。

メモリフットプリントが小さい楕円曲線ライブラリを探していたらmicro-eccというのがあって良さそうでしたが、SHA256やHMACも必要なのとHTTPS通信のTLSの処理と実装を共通化したほうが良いのでmbedTLSを使うことにしました。

ただ、実装していて気付いたのですが、ESP-IDFの mbedtls はHKDF関連の関数が標準では含まれません。`CONFIG_MBEDTLS_HKDF_C=y` として ESP-IDFをビルドする必要があります。
問題なのは、`framework = arduino` にしてる場合で、sdkconfigファイルで構成を変更できないので、`framework = arduino,espidf`としてespidfも一緒にビルドする必要が出てきます。少し悩みましたが、HMACさえあれば簡単に作れるので、HKDF関数は自作しました。

- https://github.com/h2zero/NimBLE-Arduino
- https://github.com/Mbed-TLS/mbedtls
- https://github.com/kmackay/micro-ecc

## Matterの構成

すごく雑に書くと以下のような構成になっています。

```mermaid
block-beta
  columns 3
  Application:3
  DM["Data Model"]:3
  I["Interaction Model"]:3
  M["Message/Security"]:3
  block:group1:2
    columns 1
    TCP/UDP
    IPv6
  end
  block:group2:1
    BTP:1
  end
  WiFi Thread BLE["BLE GATT"]
```


利用までの流れは、機器の設定をするためのPASE(Passcode-Authenticated Session Establishment)セッションでのコミッショニングと、CASE(Certificate Authenticated Session Establishment)セッションでのホームハブとの連携に分かれます。PASEとCASEは認証方法が違うだけで、基本的に同じことができるので、PASEセッション上でも機器の操作はできます。

今回はWiFiの設定をしたいのでBLEを使ってPASEで接続してコミッショニングを行います。

コミッショニングは主に以下のことを行います。

1. Matter対応アプリ上で、QRコードのスキャンやペアリングコード番号を入力
2. 該当する機器が出しているBluetoothのAdvertisingを探します
3. 機器にPASEで接続します
4. 機器のDAC(Device Attestation Certificate)とCD(Certification Declaration)を確認
5. ルート証明書とNOC(Node Operational Credentials)を設定します
6. ネットワークの設定をしてWiFiに接続します

ネットワークに接続できたら、NOCを使ってホームハブ等との間でCASEでの認証を行い、スマートホーム機器が使えるようになります。

### BLE Advertising

通常、BluetoothのAdvertisingにはService UUIDのリストを入れるフィールドがあるので、特定の機能を持った機器を探す場合はその値を使います。ただし、Matterの場合は `Scan Response Data` ではなく `Advertising Data` に入れるようです。BLEに詳しくなく最初Manufacturer Dataと混同してしまいましたが、もっと低レイヤーのデータでNimBLEでは `NimBLEAdvertisementData.setServiceData()` でセットできます。あまり直接使わないので、BLEライブラリによってはpublicなメソッドが無いかもしれません(確認に使っていたGoのライブラリではアクセスできませんでした)

Discriminator, VendorID, ProductID などの情報を入れます。
HomeアプリなどはQRコードなどで読み取った機器の情報に一致するものを探して接続します。

### BTP

BLEのGATTもServiceやCharacteristicsといった、MatterのClusterやAttributeに近い仕組みを備えていますが、機能別にCharacteristicsを用意するようなことはせず、送受信用のTX, RX Characteristicsを用意してパケット通信(BTP = 	
Bluetooth Transport Protocol)を行います。簡単なフロー制御やメッセージを複数のパケットに分割する機能があります。

TX, RX は接続するクライアント側から見て、送信、受信です。

パケットの構造はシンプルなので仕様書を見ながら実装すれば問題ないと思います。

ほとんどのメッセージは小さいので1メッセージ1パケットになりますが、CSRや証明書が大きいので一部のメッセージが収まりません。実装時にはパケットのフラグメントに対応する必要があります。

データサイズが最大900バイトのようなので、送受信バッファが1KB程度必要そうです。

#### BTP Handshake

```mermaid
sequenceDiagram
    participant I as Commissioner(Home App)
    participant R as Commissionee(ESP32)
    I->>+R: BTP Handshake Request
    R->>-I: BTP Handshake Response
    Note over R,I: BTP Packets ...
```

対応しているバージョンや、MTU、ウインドウサイズを交換します。

以下は Versioin(4), MTU(0xF4), Window Size(5) の設定する例です。全部固定値で良いと思います。

```
Request: 65,6C,04,00,00,00,FF,00,05
Response: 65,6C,04,F4,00,05
```

以降は、次に書くMessageをBTP上で送受信することになります。

### Message

ヘッダが Message Header + Protocol Header の２層構造になっています。

Message Headerには宛先やメッセージカウンタなどの最低限の情報が入っています。
Protocol Headerにはメッセージの種類や含まれるデータの種類等が入っています。
Protocol Header以降は暗号化の対象です。

メッセージカウンタは32ビットの値で任意の値から始めて良いようです。レスポンスメッセージに受信したカウンタを入れてACKするようですが、少なくともBTPで通信しているときは不要そうです。

#### Matter TLV

メッセージのペイロードは TLV (Tag-Length-Value) としてシリアライズされています。

TLVについては以下の解説が分かりやすいです。

https://engineering.nature.global/entry/blog-fes-2023-matter-tlv-encoding

#### 認証と鍵交換

機器を設定するためのコミッショニング時はpasscodeを含むQRコードなどを使ってPASEで接続します。

鍵交換はSPAKE2+で行います。Matterで使う楕円曲線はP256、ハッシュ関数はSHA256に統一されているようです。

https://datatracker.ietf.org/doc/pdf/draft-bar-cfrg-spake2plus-02.pdf

いきなり楕円曲線の話が出てきますが、必要な計算はTLSライブラリに含まれるものだけなので、基本的な扱いだけおさえれば大丈夫です。

```mermaid
sequenceDiagram
    participant I as Initiator(Home App)
    participant R as Responder(ESP32)
    I->>R: PBKDFParamRequest
    R->>I: PBKDFParamResponse
    I->>R: PAKE1(pA)
    R->>I: PAKE2(pB, cB)
    I->>R: PAKE3(cA)
    R->>I: StatusReport
    Note over R,I: Encrypted Messages ...
```

ConfirmationKeys(ca[16], cb[16]) と SessionKeys(I2R[16], R2I[16], Challenge[16]) を生成するのが目的です。

このうち、`cA=HMAC(ca, pB)` , `cB=HMAC(cb, pA)` を交換して相手が正しく鍵を生成できたことを確認します。

鍵を生成するとき、`PBKDFParamRequest` と `PBKDFParamResponse` をハッシュ関数に渡すのですが、メッセージ自体なのかペイロードのTLVなのか仕様から読み取れず困りました。ペイロードを使うのが正しいみたいです。

以降のメッセージにはPBKDFParamRequest/Responseで交換したSessionIdを使うことになります。また内容が暗号化されます。

鍵交換は、SessionId=0, ProtocolId=0 のメッセージで行います。PBKDFParamに入っているセッションIDは鍵交換が完了して暗号化されたメッセージを送受信するときに使います。

:::note warn
仕様を読む上での注意点として、Matterが使うSPAKE2+は最新版ではなく、2020年のdraft版で実装されています。ほぼ同じですがConfirmationKeysの鍵長が半分です(仕様に書いてあるリンク先を読めば良いのですが、ググって出てきたドキュメントのテストベクタでテストしていてなかなか間違いに気づかなかった。。。)

あと、Matter 1.0の仕様ではハッシュ関数に通す `Matter PAKE V1 Commissioning` という文字列がありますが、`Matter` ではなく `CHIP` とする必要があります。名前が変わったときに仕様書内を全部置換してしまったのだと思いますが、ここを変えると互換性壊れるので、世の中の実装はそのままCHIPを使っています。そしてMatter 1.2の仕様ではCHIPに戻っていました。
:::

#### Messageの暗号化

Protocol Header以降がI2R(Initiator→Responder),R2I(Responder→Initiator)を鍵としてAES-ECCで暗号化されています。暗号化されたメッセージはtagデータが付くので16バイト長くなっています。

暗号化されているかどうかのフラグは無いのでセッションIDで判別するのが良さそうです。

### Data Model と Interaction Model

Matter対応の機器は、Node / Endpoint / Cluser という階層を持ちます。

- Node: Matter Protocolでやり取りする対象
- Endpoint: 機器 (例えばテレビ)
- Cluster: 機能 (例えば電源、ボリューム、チャンネル切り替え)

単体の機器の場合は、Node = Endpointになりますが、[Nature Remo](https://nature.global/)のようなスマートリモコンは一つのNodeで複数の家電を操作できます。

各Clusterには、Attribute, Event, Command などが定義されていて、Read/Write/Invokeなどの操作ができます。

Clusterは人間が操作するものだけでなく、ネットワークの設定や機器の認証に必要なClusterも存在します(この記事で使いたいのはこっち)

#### リクエスト

主な操作：
- Read: AttributeやEventの内容を取得します
- Invoke: Commandを実行します
- Write: Attributeに値を書き込みます。今回は実装しませんでした

たとえば、Readするときは ReadRequest の Endpoint, Cluster, AttributeのIDを入れてリクエストすると、ReportDataメッセージが返ってきます。

### Commissioningに必要なCluster

- 0x001d Descriptor Cluster
- 0x0028 Basic Information Cluster
- 0x0030 General Commissioning Cluster
- 0x0031 Network Commissioning Cluster
- 0x003e Operational Credentials Cluster

実際のやり取りの例：

必須とされているものだけでも全部実装すると大変なので、実際にAndroidのHomeアプリから接続してみて、要求されたものを実装しました。なのでiPhoneや他のアプリの場合は追加の実装が必要になるかもしれません。

```mermaid
sequenceDiagram
    participant I as Initiator(Home App)
    participant R as Responder(ESP32)
    I->>+R: Read ServerList(001d:0001)
    R->>-I: ReportData
    I->>+R: Read FeatureMap(0031:fffc)
    R->>-I: ReportData
    I->>+R: Read VendorID(0028:0002)
    R->>-I: ReportData
    I->>+R: Read ProductID(0028:0004)
    R->>-I: ReportData
    I->>+R: Read SupportedFabrics(003e:0002)
    R->>-I: ReportData
    I->>+R: Read CommissionedFabrics(003e:0003)
    R->>-I: ReportData
    I->>+R: Invoke CertificateChainRequest(003e:0002)
    R->>-I: InvokeResponse CertificateChainResponse
    I->>+R: Read ServerList(001d:0001)
    R->>-I: ReportData
    I->>+R: Read FeatureMap(0031:fffc)
    R->>-I: ReportData
    I->>+R: Read Fabrics(003e:0001)
    R->>-I: ReportData
```

まず、デバイスに関する情報を取得します。

- Read ServerList まずデバイスが対応しているClusterのリストを取得します
  - 必須なClusterはServerListに含まれなくてもHomeアプリはアクセスするようですが、 0x0031 が含まれない場合は WiFi に関する操作はスキップされました
- Read FeatureMap　WiFiに対応しているか確認します
- VendorID, ProductID を取得
- Fabricsに関する情報はCASEが未実装なので一旦適当に返しておきます
- デバイスのDACを返します
  - 固定で良いのでDERフォーマットのバイナリをコードに埋め込んでおいて、それを返すのが良いでしょう
  - サイズが大きいのでBTPのパケットが分割されます
- ServerList, FeatureMap, Fabricsを２回取得してますが、理由は不明

```mermaid
sequenceDiagram
    participant I as Initiator(Home App)
    participant R as Responder(ESP32)
    I->>+R: TimedMessage(1000)
    I->>+R: Invoke ArmFailSafe(0030:0000)
    R->>-I: InvokeResponse ArmFailSafeResponse
    I->>+R: Read LocationCapability(0030:0003)
    R->>-I: ReportData
    I->>+R: TimedMessage(1000)
    I->>+R: Invoke SetRegulatoryConfig(0030:0002)
    R->>-I: InvokeResponse SetRegulatoryConfigResponse
    I->>+R: Invoke CertificateChainRequest(003e:0002)
    R->>-I: InvokeResponse CertificateChainResponse
    I->>+R: Invoke AttestationRequest(003e:0000)
    R->>-I: InvokeResponse AttestationResponse
    I->>+R: Invoke CSRRequest(003e:0004)
    R->>-I: InvokeResponse CSRResponse
```

- ArmFailSafe で排他的に機器を操作するタイムアウトを設定します
- LocationCapability でどこで使う機器かを返します Indoor/Outdoor
- DACを返します
  - 送られてきたnoceと一緒に秘密鍵を使ってECDSAで署名します
- CDからX.509 CSRを生成して返します
  - レスポンスをECDSAで署名する必要があります
  - 一応毎回CSRを生成しるようにしましたが、固定値にできそうです
- 開発用のDACやCDを返すと警告画面が出ます
  - 警告画面が出た時点で接続が切れているので、コミッショニングプロセスはは最初からやりなおし

```mermaid
sequenceDiagram
    participant I as Initiator(Home App)
    participant R as Responder(ESP32)
    I->>+R: Invoke AddTrustedRootCertificate(003e:000b)
    R->>-I: InvokeResponse
    I->>+R: Invoke AddNOC(003e:0006)
    R->>-I: InvokeResponse　NOCResponse
    I->>+R: Invoke AddOrUpdateWiFiNetwork(0031:0002)
    R->>-I: InvokeResponse NetworkConfigResponse
    I->>+R: Read ConnectMaxTimeSeconds(0031:0003)
    R->>-I: ReportData
    I->>+R: Invoke ConnectNetwork(0031:0006)
    R->>-I: InvokeResponse ConnectNetworkResponse
```

- ルート証明書やNOCが追加されます
   - CASEで接続するときに必要ですが、とりあえず使わずに捨てています
- WiFiの設定をします
   - スマホが接続しているSSIDとパスワードが含まれています
   - 仕組み上は、機器が実際に使えるアクセスポイントをスキャンしてから設定できそうですが、Homeアプリは常に現在接続中のアクセスポイントを設定するようです(ESP32は5GHz未対応なので2GHzのアクセスポイントに限定したいができなかった…)
- ネットワークに接続します
- PASEセッションでの処理はこれで完了です

```mermaid
sequenceDiagram
    participant I as Initiator(Home App)
    participant R as Responder(ESP32)
    I->>+R: TimedMessage(1000)
    I->>+R: Invoke ArmFailSafe(0030:0000)
    R->>-I: InvokeResponse ArmFailSafeResponse
```

切断前に、ArmFailSafeを1秒に更新するようです。

この後、WiFi経由で接続確認が行われます(ちゃんと接続できないとタイムアウトまで待たされます)

単純にIPアドレスを受け渡したりして接続するわけではなくFabricIdから作ったホスト名をmDNS等で名前解決できるようにする必要があります。また、IPv4ではなくIPv6を使う必要があります。

## 最後に

なんとかAndroidのホームアプリを使ってWiFi設定を行えるようになりました。
あとは、CASEセッションを無視しているせいで、ホームアプリ上で接続中のまま止まるのはどうにかしたいです。

実装したものは、GitHubにあります： https://github.com/binzume/mini-matter

ライブラリのインターフェイスは将来整備するかもしれませんが、小さい実装なのでforkして改造した方が手っ取り早いと思います。

#### 気になったこと

- IPv6で接続して名前解決できるところまで要求しておきながら、ホームハブ等のハードウェアを購入しないと実際の操作ができないようになってるのなんで？
- Androidはハブ無しでもコミッショニングはできたけど、iOSはハブが無いと何もできない？
- 色々なDevice Typeがあるけど、基本的に既に普及しているIoT機器しかないので、自作の怪しげな機器を当てはめようとすると「とりあえずスイッチあるし電球のふりをしよう」みたいになりそう