dotenv を塞いでも API キーは漏れた。置き場所を pass に移すまで
まとめ
- 秘密を守っていたのは暗号ではなくファイル名だった。dotenv だけ読み取り禁止にしても、launchd plist、systemd unit、compose、CI 設定は素通しになる
- 置き場所は消費者ごとに分ける。起動時に一度読むだけのプロセスは暗号化ストアから復号、CLI エージェントが毎回叩く経路は 0600 の別ファイル(復号は 1 回 0.7 秒)
- 移行には空値ガードが要る。鍵が空のまま起動すると認証が黙って無効化され、落ちてくれない
発端は plist の全文出力
手元で常駐サーバを 1 本動かしている。 外部サービスの API を叩くのでキーが要る。 LaunchAgent の plist の EnvironmentVariables に平文で書き、インストールスクリプトがシェルの環境変数から値を焼き込む形にしていた。
そこに CLI エージェントを向けた。 常駐ジョブの取得間隔を確認させたら、plist を全文出力して、API キーがそのまま会話ログに載った。 知りたかったのは実行コマンドと間隔の 2 つだけだ。
やりたいことは決まった。 次に同じ聞き方をされても、値が出てこない状態にする。
.env 系は対策済みだった。 読み取り禁止リストに入れ、サブプロセス経由の迂回もフックで塞いである。 それでも漏れたのは、対策が見ているのがファイル名だからだ。
読み取り禁止リスト(ファイル名で判定)
.env / .env.local / .env.production → 塞がれる
~/Library/LaunchAgents/*.plist → 素通し ← 平文がここにあった
/etc/systemd/system/*.service → 素通し
compose.yml / .github/workflows/*.yml → 素通し秘密がどこにあるかは、ファイル名では決まらない。
置き場所を消費者で分ける
移行先を決める段で、環境変数から同じキーを読むスクリプトが 15 本以上あると分かった。 全部を暗号化ストアに寄せると、エージェントがツールを呼ぶたびに復号が走る。
実測 1 回 0.7 秒。数十回叩く経路なので無視できない。
表1: 消費者ごとの置き場所
| 消費者 | 置き場所 | 理由 |
|---|---|---|
| 常駐サーバ(起動は 1 日数回) | pass(GPG 暗号ストア)から起動時に復号 | 復号は 1 回。平文がディスクに残らない |
| スクリプト 15 本以上(エージェントが毎回叩く) | 0600 の git 管理外ファイル + エージェント側の読み取り禁止 | 復号を挟むと 0.7 秒がツール呼び出しごとに乗る |
| パス、タイムアウト、機能フラグ | 平文の設定ファイル | 漏れても誰も何もできない。暗号化はコストだけ |
判定は「漏れたら誰かが何かをできてしまう値か」の一点で足りる。
無人で復号させる
常駐サーバは KeepAlive で動く。 ログイン直後に立ち上がるので、そのとき復号エージェントのキャッシュは冷えている。 人間にパスフレーズを聞きに行く実装だと、起動に失敗して再起動ループに入る。
表2: 無人で復号させる方式
| 方式 | 採否 |
|---|---|
| GUI の pinentry + OS のキーチェーンにパスフレーズ保存 | 採用。初回にチェックを入れるだけ |
| パスフレーズ無しの GPG 鍵 | 却下。鍵ファイルを読める者がストアを開ける |
| OS のキーチェーンに直接入れる | 却下。暗号化ストアを正典に据える方針から外れる |
2 段目を却下したのは、防御が「平文ではない」だけになるからだ。 今回塞ぎたかったのは、まさにファイルを読める相手だった。
再起動ループを避けるため、ThrottleInterval は 60 秒にしている。
空の鍵で起動させない
一番怖いのは、鍵が取れないことではない。 空の鍵で起動してしまうことだ。
外部 API を叩くコードは、キーが空文字でもたいてい起動する。 空の認証ヘッダで弾かれ、その例外を上位で握りつぶす実装なら、プロセスは動いたまま何も取れない。 壊れているのに落ちない。
# ❌ 復号エラーが 2>/dev/null で消え、空文字が「取得成功」になる
KEY="$(pass show some/entry 2>/dev/null)"
[ -n "$KEY" ] && start_server
# ✅ 失敗と「取れたけど空」を別々に見て、どちらでも止める
d=$(mktemp -d) && trap 'rm -rf "$d"' EXIT
pass some/entry > "$d/key" || { echo "decrypt failed" >&2; exit 1; }
[ -s "$d/key" ] || { echo "empty secret" >&2; exit 1; }ガードは起動ラッパー、インストールスクリプト、make の 3 経路すべてに置いた。 一時ファイル経由にしているのは、値を引数に置かないため(引数は ps から読める)。
移行後に平文はどこに残るか
表3: 平文の所在
| 場所 | 移行前 | 移行後 |
|---|---|---|
| シェルの環境変数ファイル | 2 行(1 行はコメントアウト済み。値は残っている) | 0 |
| LaunchAgent の plist | 1 箇所 | 0 |
| 0600 の専用ファイル(読み取り禁止リスト対象) | なし | 1 |
コメントアウトされた 1 行を見落としかけた。 # が付いていても値は平文のままだ。
動作確認は復号が通ったところで止めない。 プロセスが running、HTTP 200、その先でログに外部 API から 300 件取得できている行まで見て終わりにした。 200 はサーバが生きている証拠でしかなく、鍵で認証が通っている証拠ではない。
残った平文は 0600 の 1 ファイルで、読み取り禁止リストに入っている。 「平文ゼロ」ではなく「読める相手を 1 箇所に絞った」状態だ。 作業ツリーが平文のままになる透過暗号化(git-crypt でリポジトリ内の機密ファイルを透過的に暗号化するで扱った方式)を選ばなかったのも同じ理由になる。