Matterコントローラを自前実装した記事で書いたとおり、家のMatter機器は自作CLI mat と、warmなセッションを保つ常駐デーモン matd で動いています。mat fabric init で家のfabric(機器との信頼関係の単位)を作ってから、この基盤には触れていない鍵がひとつありました。IPK(Identity Protection Key)です。

IPKは、CASE(機器との暗号化セッションを張る手続き)で使う宛先IDの秘匿と、グループ一斉制御(groupcast)の鍵素材を兼ねています。Matter仕様(§4.15.2 / §11.2.6)は、管理者が KeySetWrite コマンドで定期的にローテーションすることを想定しています。うちのIPKは、mat fabric init で生成したまま、あるいは chip-tool 時代に採用した固定値のまま、一度も替わっていませんでした。

これを安全にローテーションする設計に着手して、最初に立てた素朴な案が、思わぬコストを持っていることが分かりました。

素朴な案:ローテーションしたらmatdを再起動する

各機器に新旧2本のIPKを書き込み、controller側の記録を新しい値に切り替える——ここまではローテーションの本体で、迷いはありません。問題はそのあとでした。

matd は起動時にfabricの資格情報(credentials)をメモリに読み込んで保持しています。IPKもそこに含まれる。ローテーションでcontroller側の記録を切り替えても、稼働中の matd は起動時のメモリの値をそのまま使い続けるので、新しいIPKを反映するにはプロセスを再起動するしかありません。実装済みの group provision コマンドも、この前提で「matdが動いていたら再起動してください」という注記を出す作りでした。

再起動の何が問題か。matd は機器の状態変化を受け取る購読(subscription)を張りっぱなしにしています。本番で常時張っている購読は19本。再起動はこの19本すべての張り直しを意味していて、約2分間の盲目期間が発生します。その間、機器の状態変化はmatdに届きません。加えて、張り直しの最中にmandoという別プロセスとの初期化タイミングが競合してWARNログが出る、というおまけつきでした。

鍵をローテーションするたびに家が2分間ブラックアウトする。これは受け入れられませんでした。

何を読み直す必要があるのか、切り分けた

再起動が重いなら、再起動しない方法を探すのが次の一手です。ただし闇雲に「差分だけ読み直す」機構を足す前に、そもそも matd が起動時に抱え込んでいる状態が何なのかを洗い直しました。

分かったのは、matd がメモリに固定して持ち回っているのはfabricの資格情報だけだということです。グループ制御まわり——鍵セットやグループマップ——は、実は起動時に読み込んで保持する設計にはなっていませんでした。送信のたびに毎回KVS(資格情報のストア)から読み直す作りだったのです。つまり group provision の「matdを再起動してください」という既存の注記は、調査してみるとそもそも事実に反していました。グループ設定は再起動しなくても最新化されていたということです。

再起動が本当に必要なのは、IPKを含む資格情報オブジェクトの差し替えだけ。だとしたら、daemon全体を落として19本の購読を巻き添えにする必要はなく、その1オブジェクトだけを安全に挿げ替える経路を作ればいい、という判断基準が立ちました。

選んだ設計:daemon全体ではなく、資格情報1個だけを挿げ替える

matd reload という管理コマンドを追加しました。ソケット越しに {"op":"reload"} を送ると、matd はKVSから資格情報を読み直し、内部で保持している資格情報のポインタ(Arc)を、ロックを取ってアトミックに差し替えます。差し替えは1回のwrite操作で完結するので、「半分だけ新しい資格情報」という中間状態は存在しません。

差し替えの影響範囲は次の確立(セッション接続)からだけです。すでに張っている購読やCASEセッションはそのまま生き続けます。捨てたのは「daemonを丸ごと作り直す」という安全だが荒っぽいやり方で、代わりに「本当に古くなった部分だけを差し替える」やり方を選びました。

一方で、意図的に切り捨てた範囲もあります。fabric自体のアイデンティティ(fabric ID・node ID・ルート鍵)が変わるような差し替えは、reloadでは受け付けません。この場合はエラーを返して再起動を案内します。ここまでは同じ仕組みで済ませられそうに見えますが、資格情報の中身がまるごと別物になるケースまでホットスワップの対象にすると、進行中のセッションとの整合を保証できなくなる。だから狙い撃ちの範囲をIPKローテーションのような「同じfabric内での鍵の更新」に絞り、それ以外は素直に再起動に倒しました。

mat fabric rotate-ipk がローテーションをcontroller側で確定させたあと、このreloadを自動で叩くようにしたので、運用側が手動でreloadを叩き忘れる心配もありません。

ローテーション自体の設計:デバイス側の受理は「繋ぎ直せたか」でしか確認できない

reload側の設計と並行して、ローテーション本体にも判断が要りました。

ひとつは、新旧の鍵をどう配るかです。機器に新しいIPKだけを送って古い方を即座に破棄すると、書き込みが一部の機器で失敗したときに詰みます。そこで配布は常に「現行+新」の2本を書く形にしました。機器側は2世代分のIPKを両方受理できる仕様なので、ローテーションの途中でも機器はどちらの鍵でも応答できる。1本ずつ順番に全機器へ配ってから確定する、という運用が可能になります。

もうひとつは、機器が本当に新しい鍵を受け取ったかどうかの確認方法です。KeySetRead コマンドは鍵の中身までは返してくれないので、「書き込みが成功した」というレスポンスだけでは受理の証明になりません。確実な証拠は、新しいIPKを使ってその機器にもう一度接続できるかだけでした。書き込み後に新IPK側の鍵で再接続を試み、それが通ったら初めて「この機器は新しい鍵を持っている」と確定する設計にしています。

最後に、途中でクラッシュしても壊れない順序にもこだわりました。新しい鍵を生成したら、機器に触る前にまずcontroller側のストアへ「これから配る鍵」として永続化する。そのあとで機器への配布を始める。この順序なら、配布の途中でプロセスが落ちても、再実行時に同じ鍵で再開できて、3本目の鍵が生まれることはありません。全機器の受理が確認できて初めて、現行の鍵として確定します。1台でも失敗すれば確定させず、次回の実行にそのまま持ち越します。

答え合わせ:本番のmatdコンテナで確認した

設計だけでなく、実機でも確認しました。本番で matd が動いているコンテナに対して matd reload を単体で叩いたところ、IPKはローテーション前なので unchangedreload_count は1。直後に購読の状態を見ると、19本すべてが張り直しなしで established のままでした。狙いどおり、購読を巻き添えにせずに資格情報だけを差し替えられています。

鍵をローテーションするための機構を作っていたら、その前段——常時19本張っている購読を毎回巻き添えにしていた再起動運用——のほうが本題になりました。設計を進めるときに「ここは前から決まっていることだから触らない」と素通りしていたら、2分間のブラックアウトは温存されたままだったはずです。

mat以前書いたとおり、chip-toolのラッパーからフルスクラッチのRust実装へと作り替えてきたツールです。今回の一件は、作り替えたあとも「動いているから触らない」を続けていると、動いてはいるけど誰も検証していない前提が積み上がっていくという話でもありました。fabricの鍵を自分で管理する以上、ローテーションの運用まで含めて自分の手で説明できる状態にしておきたいと思っています。