リモートコマンドをブラウザで署名する理由:Fleetdの認可と信頼境界

Fleetd v0.3.0のEd25519署名、対象の結び付け、リプレイ防止、ローカル権限、鍵の失効を解説。管理画面とターミナルの限界も整理します。

管理画面に機器が接続できたら、次はブラウザからコマンドを実行したくなります。ディスクの確認、サービスの再起動、ターミナルでの作業。便利になる一方で、管理画面はほかの機器を変更する権限も持ち始めます。監視が止まることと、コマンド実行機能が侵害されることでは、影響が大きく違います。

前回は、外向きの接続、機器の承認、識別情報を説明しました。今回は、すでにサーバーを認識しているエージェントが、なぜコマンド実行前にブラウザの署名まで検証するのかを掘り下げます。対象は Fleetd v0.3.0 です。署名の効果だけでなく、ブラウザへ届くコードの信頼性と、ターミナルを開いた後の境界も扱います。

ログイン済みでも、すべての指示を実行してよいわけではない

一般的な構成では、管理者がログインし、サーバーが操作を認可し、エージェントが受け取った指示を実行します。実行を認める権限はサーバーに集中します。

Fleetd はエージェント側にもう一つ判断を置きます。この機器が信頼する鍵によって、この操作は認可されているか。 ブラウザがリクエストを作成して署名し、サーバーが転送し、エージェントが自分の設定に従って検証します。ログインや通信相手の認証も引き続き必要です。それぞれ確認している対象が違います。

対象とコマンドを確認 → ブラウザで署名
→ サーバーが転送 → エージェントで認可を検証 → 実行

認証済みの接続を通って届いたというだけでは、実行資格は得られません。

署名するのは操作全体

uptime という文字列だけに署名しても、対象機器、有効な時間、再送の扱いは決まりません。Fleetd では、次の情報を含むリクエスト全体に署名します。

フィールド 役割
v プロトコルのバージョン
id 今回の認可を識別し、重複を検出する
kind コマンド実行とターミナル開始の区別
targets 認可された機器の指紋
argv または action プログラムと引数、またはローカル定義の操作
issued_at、expires_at 受け付ける時間の範囲
timeout 実行時間の制限。エージェント側の上限にも従う

現在のコマンド画面は入力を ['sh', '-c', 入力内容] に変換します。uptime を入力すると、この引数を含むリクエストが署名されます。パイプや && が使えるのは、明示的にシェルを起動しているためです。

ブラウザは JSON にシリアライズしたバイト列を Ed25519 で署名します。エージェントは受け取ったバイト列そのものを検証してから JSON を解析します。途中で uptime を reboot に変更したり、対象を追加したりすると、元の署名は通りません。最初から複数台を指定した場合は、各対象での実行が正当です。対象への結び付けは認可範囲を限定する仕組みで、一台限定という意味ではありません。

認可の有効性はエージェントが判断する

エージェントは trusted_signers に信頼する公開鍵を保存します。リクエストに公開鍵が添付されているだけでは不十分で、その鍵がローカルのリストに含まれている必要があります。そうしなければ、誰でも自分で作った鍵で自分の操作を認可できてしまいます。

署名に加えて、種類、対象に自分の指紋があること、時刻、ローカル権限も確認します。未知の JSON フィールドも拒否し、リクエストの解釈が食い違う余地を減らします。

ブラウザは通常、五分間有効なリクエストを作ります。エージェントは二分の時計のずれを許容し、有効期間が十分を超えるリクエストを拒否します。ここでいう五分は認可を受け付ける期間であり、コマンドの実行時間ではありません。実行タイムアウトは別です。また許容差があるので、五分を過ぎた瞬間に必ず拒否されるわけでもありません。両側の時計を適切に維持する必要があります。

これらを合わせて、信頼できる認可が「今、この機器」に適用できるかを判断します。

リプレイ防止と「必ず一回だけ実行」は違う

正しく署名されたリクエストも、そのまま繰り返し送信できます。Fleetd はブラウザで 16 バイトの乱数から ID を生成し、エージェントは認可に成功した ID を記録して再利用を拒否します。

記録先は状態ディレクトリの seen.json です。既定のディレクトリは /var/lib/fleetd。状態が保持されていれば、通常のプロセス再起動だけで、まだ有効なリクエストが再利用可能になることはありません。永続化に失敗した場合も実行へ進みません。

ただし、業務上の効果が「ちょうど一回」発生する保証ではありません。ID は同時実行数の確認やプロセス起動より先に記録します。枠が埋まっているため起動しなくても、ID は消費されることがあります。逆に、機器への変更は済んだのに、切断によって管理画面へ結果が届かない場合もあります。

再度実行ボタンを押すと、新しい ID による別の認可になります。エージェントには、失敗のやり直しなのか、成功済み操作の繰り返しなのかは分かりません。データベース移行、設定変更、更新では実際の状態を確かめ、できるだけ冪等に設計します。状態ファイルの消失やディスクの巻き戻しも、普通のプロセス再起動とは別に考える必要があります。

署名が正しくてもローカル権限に従う

権限 受け付ける操作
monitor 監視のみ。コマンドとターミナルは拒否
allowlist 信頼できる署名付きのローカル定義操作
full 信頼できる署名付きの任意コマンドとターミナル

状態を見るだけの機器は monitor のままで十分です。少数の保守操作には許可リストを使えます。systemd と nginx がある機器なら、状態確認だけを次のように定義できます。

permission: allowlist
allowlist:
  nginx-status: [systemctl, is-active, nginx]

これは権限部分だけの例で、完全な設定には接続情報や信頼する公開鍵も必要です。署名済みリクエストには action: nginx-status を含め、実際のプログラムと引数はエージェントがローカル設定から取得します。送信側が任意の引数を追加することはできず、action と argv の同時指定も拒否します。

v0.3.0 の通常のコマンド画面はシェルコマンドを送信し、定義済み操作の選択 UI はありません。 入力欄に nginx-status と書いても、allowlist 機器で上の操作を呼び出せるわけではありません。プロトコルが持つ機能と、画面から使える機能には差があります。

現在の導入方式ではエージェントは root で動作し、full のコマンドも相応の権限を持ちます。署名は入力ミスや破壊的な操作を判別しません。すでに認可された root コマンドなら、エージェント自身の設定も変更できます。ローカル権限は未認可の要求を制限するもので、認可済み root プロセスを低権限化するものではありません。

ブラウザの鍵にも更新と失効がある

鍵ペアは WebCrypto で生成し、秘密鍵はエクスポート不可に設定して IndexedDB に保存します。通常の登録処理でサーバーに送るのは公開鍵であり、秘密鍵ではありません。

別のパソコンへ移ったりサイトデータを消したりすると、ログインできても、既存機器が信頼する署名を作れなくなることがあります。新しいブラウザで鍵を生成し、その公開鍵を各機器で信頼する必要があります。サーバーへの登録だけで既存クライアントの信頼リストが自動更新されることはありません。

失効も機器まで反映する必要があります。管理画面から公開鍵を削除しても、設定済み機器での信頼は解除されません。 各機器の trusted_signers から削除し、対応するサービス管理方式でエージェントを再起動します。

更新パッケージを署名するリリース鍵は別物です。コマンドの署名は操作の認可、リリース署名は配布された更新物の確認を担います。更新の回で詳しく扱います。

明確にしておきたい二つの境界

管理画面もサーバーから配信される

エクスポート不可という設定は、通常の WebCrypto API から秘密鍵の素材を取り出せないという意味です。同一オリジンの悪意あるコードがその鍵を使って署名することまでは防ぎません。

サーバーが侵害されると、以後のアクセスで改変された JavaScript を配信される可能性があります。画面に見せるコマンドと実際に署名する内容を変えることも考えられます。同じページが表示する確認ダイアログは、そのページ自身の正しさを独立して証明できません。

エージェントは、有効な署名のない改変リクエストを拒否できます。ただし、フロントエンドのコードを信頼できることが前提です。署名ツールを管理サーバーとは別に配布すれば、この依存を減らす方向に進めます。設計文書には CLI 署名ツールへの言及がありますが、v0.3.0 には未実装です。

ターミナルは開始時だけ署名する

ターミナルを開くときは kind: shell の署名を検証し、ローカルの full 権限を確認します。しかし、その後のキー入力は既存セッションで送られ、個別のブラウザ署名はありません。侵害されたサーバーが、開いているターミナルに入力を注入する可能性があります。

必要なときに開き、使い終わったら閉じることが大切です。明確な一回のコマンドで済む作業は、継続的な対話セッションより認可範囲を確認しやすくなります。開始時の署名が、その後のすべての入力を同様に保護するわけではありません。

署名はコマンドや出力を隠す機能でもありません。エージェントとサーバー間の暗号化は通信経路を守りますが、サーバーは通信の終端です。サーバーにも読めないブラウザ・エージェント間の完全な秘匿通信とは異なります。

拒否される経路にも注目する

動作デモでは結果が表示される場面が目立ちます。しかし認可では、何を拒否するかも重要です。既存のテストには、署名後の内容変更、未信頼の鍵、対象違い、期限切れ、未来すぎる時刻、権限不足、状態再読込後の ID 再利用が含まれます。各ルールの回帰を防ぐためのテストであり、包括的なセキュリティ監査ではありません。改変フロントエンド、ターミナル入力、すべての導入環境が検証済みになるわけでもありません。

自分で運用する立場では、信頼する鍵、認可対象、有効時間、ローカル権限、重複処理を説明できることに価値があります。定義済み操作の UI、独立した署名ツール、ターミナルの保護は、今後改善する対象として具体的です。

次回は導入に近い話として、同じ Go エージェントを Linux サーバーと OpenWrt ルーターで動かす方法を扱います。

コメント・議論

質問や実践の経験をお寄せください。コメントは承認後に表示されます。メールアドレスは公開されません。 コメントのプライバシー

コメントを読み込み中…