投稿

AI エージェントに API キーを読ませない — macOS Keychain ACL とローカルブローカー

AI エージェントに API キーを読ませない — macOS Keychain ACL とローカルブローカー

まとめ

  • 暗号化ストア(pass 等)はエージェントに対する境界にならない。gpg-agent が解錠済みなら、エージェントも同じコマンドで復号できる
  • 防げるのは漏洩だけで、悪用は防げない。ここを切り分けないと、いつまでも「もっと堅くする方法」を探し続けることになる
  • macOS で実際に効いたのは Keychain の ACL だけ。ただし信頼される単位は「Keychain API を呼んだバイナリ」なので、シェルスクリプトでは成立しない

環境変数に置いた時点で読める

エージェントのシェルは自分と同じ権限で動く。手元で測ったら uid も admin グループも login keychain も同一だった。zsh を直接起動すれば、エージェント向けに用意したガードもフックも素通りする。

echo $API_KEY を止めるフックは書ける。書いたし、実際に何度も止まった。ただし止まるのは「知っているパターン」だけで、python -cos.environ を読む、base64 に通す、ファイルに書いて読み直す、といった経路は無限にある。列挙型の防御は列挙し漏らした瞬間に破れる。

ひとつだけ救いがあって、他プロセスの環境変数は読めない。

表1: 他プロセスの環境変数が読めるか

対象 ps eww の出力 env が見えるか
自プロセス 1454 バイト(PATH= を含む)
別プロセス(同一ユーザーの子) 71 バイト(コマンド行のみ)

SIP 有効下の macOS はこれを拒否する。だから「別のプロセスに持たせて、自分は持たない」形にすれば、少なくとも env 経由の漏洩は消える。

どこに置けば読めなくなるか

表2: 選定フロー

手順 判断すること 結果
1 その秘密は HTTP API 用か 違う(AWS の署名・DB 接続文字列)→ ブローカーでは扱えない。SSO や direnv へ
2 エージェントがその秘密に触る必要があるか 無い(人が手で打つだけ)→ pass と direnv で足りる
3 上のどちらでもない ブローカー + Keychain ACL

手順 1 で落ちるものが思ったより多い。AWS の SigV4 はリクエスト全体への署名なので、ヘッダを差し込む方式では作れない。MySQL のパスワードはそもそも HTTP ではない。ここは長期キーを廃止する(SSO / STS)方向にしか出口がない。

表3: 保管先ごとの境界

保管先 エージェントが値を取れるか 根拠 適する用途
平文の dotenv 取れる cat / source なし
pass(GPG) 取れる agent 解錠済みで --batch 復号が成功した 人が対話で使う秘密
Keychain + ACL 制限 取れない 認証を要求されて止まる エージェントに触らせたくない秘密
ブローカーのプロセス env 取れない 他プロセスの env は ps で取れない 実行時の受け渡し

pass を捨てる必要はない。ディスクから平文を消す価値は本物で、そこは効いている。ただし対エージェントの境界としては効かない。この 2 つを混ぜていたのが、自分の設計が長いこと迷走した原因だった。

表4: 復号とシェル起動のコスト

計測対象 実測 非対話 zsh 起動を 1.0 とした比
zsh -c true.zshenv のみ) 69 ms 1.0
pass show <entry> 1 回 510〜677 ms 7.4〜9.8
gpg --decrypt <entry>.gpg 1 回 193 ms 2.8
.zshenvpass を 1 本置いた場合の起動 約 570 ms 8.3

pass の遅さは復号そのものではない。gpg -d 単体なら 193 ms なので、残りの 300〜480 ms はラッパースクリプトの起動・設定読み込み・ストア探索が食っている。

問題は速度そのものより、どこに置くかで回数が変わることだった。.zshenv に書くと全 zsh 起動に乗る。エージェントのコマンド実行は毎回が新規シェルなので、20 回叩けば 10 秒増える。常駐プロセスの起動時に 1 回なら、同じ 0.5 秒でも生涯 1 回で済む。

Keychain ACL の落とし穴

ここが一番時間を食った。4 回踏んだ。

表5: 踏んだ落とし穴

現象 原因 対処
zsh ラッパーから読むと ACL が無意味になる 信頼されるのは Keychain API を呼んだバイナリ。/usr/bin/security を許可すると、それを呼べる全プロセスが通る Security.framework を直接呼ぶ専用バイナリを作る
ad-hoc 署名のバイナリを -T で許可しても毎回ダイアログが出る 署名に TeamIdentifier が無く、項目の partition list に載せられない。cdhash が一致していても出る 項目をそのバイナリ自身に作らせる(作成者として信頼される)
保存した値が途中で切れて認証が通らない security add-generic-password -w は 128 文字で切り詰める。400 文字入れて 128 文字しか返らなかった 保存もバイナリ側で SecKeychainItemCreateFromContent を呼ぶ
自作の保存処理にしたら security から読めるようになった SecAccessCreate は「任意のアプリを許可する」ACL エントリも一緒に作る SecACLSetContents で全 ACL の信頼アプリを自分自身のみに上書きする

3 番目は完全に想定外だった。長さ制限があるとは思っていなくて、「1 つ目のキーだけ通って、2 つ目以降が落ちる」という症状から逆算して見つけた。切り詰めが起きても security はエラーを返さない。黙って 128 文字で保存する。

作ったもの

エージェント ──HTTP(127.0.0.1:7788)──> Caddy ──HTTPS + 認証ヘッダ──> 上流 API
                                        ↑ 起動時に env を受け取る
                              専用バイナリ(Keychain ACL の唯一の主体)
                                        ↑
                              login keychain(ACL 制限つき項目)

Caddy の reverse_proxyheader_up で上流へのヘッダを足せる。クライアントは localhost に平文 HTTP で話すので、TLS を終端する必要がない。ローカル CA も要らない。会社の SSL インスペクションと証明書がぶつかる心配もない。

秘密は Caddyfile に書かず {env.VAR} で参照する。ここは {$VAR} と間違えると事故る。{env.VAR}caddy adapt の出力にプレースホルダのまま残るが、{$VAR} はパース前に展開されて実値が出た。

Keychain の項目は 1 つにまとめて、中身を 1 行 1 変数の NAME=value にした。項目ごとに承認ダイアログが出るので、変数ごとに項目を作るとキーを足すたびにダイアログが増える。1 項目なら何個入れても 1 回で済む。

測定を人間の操作で汚さない

これが今回いちばん効いた反省。

security から読めるか」を確かめるテストを流したら rc=0 で値が返ってきた。ACL が効いていないと判断して、SecAccessCreate の実装を疑って書き直した。実際には画面に出た認証ダイアログを本人が承認していただけだった。

同じことを 2 回やっている。1 回目は「12 秒待っても返らない=ブロックされた」を正しく観測できたのに、2 回目は本人が Deny を押したせいで userCanceledErr になり、それを「ACL が壊れた」と読んだ。

ダイアログが境界そのものである設計では、押されたかどうかが測定条件になる。自分が測定条件を管理していないことに気づいていなかった。テストを流す前に「これは誰も触らない状態で走るか」を確認する、それだけの話だった。

副産物としてひとつ良いことがある。ダイアログの要求元アプリ名を見れば、api-broker なら正常、securityTerminal なら誰かが鍵を読もうとしている、と判別できる。境界がそのままアラートになる。Keychain 項目のラベルとコメントを丁寧に書いておくと、この判別が楽になる。

残る限界

  • 悪用は防げない。ブローカー経由の API 呼び出しはエージェントにできる。allowlist を狭くしてログを取る以上のことはできない
  • ブローカーを起動する権限もエージェントにある。値は標準出力に出さない作りにしたが、完全に塞ぐなら別 OS ユーザーで動かしてバイナリを他ユーザーから読めなくする必要がある
  • GUI セッションが無い文脈(cron / SSH)では Keychain の読み出しが失敗する。常駐前提の設計なので今のところ困っていないが、バッチから使うなら別の手当てが要る

「読ませない」を完全にやるなら、行き先はエージェントにシェルを持たせないことになる。それは今使っている道具とは両立しない。だから漏洩だけを塞いで、悪用は範囲を絞って受け入れる。ここに落ち着くまでに 3 回設計を覆した。

関連: git の HTTPS リモートを SSH に自動書き換えして macOS キーチェーンの確認を止める / マネーフォワードGitHub流出事件を読み解く

トレンドのタグ