もう.envにAPIキーを平文で置くのはやめた — macOS Keychain管理CLI「LLM Key Ring」
TL;DR
LLMのAPIキーを .env に平文で置く運用が、AIエージェント時代にリスクが見えてきた。macOS Keychainに暗号化保存して管理するCLIツール LLM Key Ring (lkr) をRustで作った。
- Keychainに保存 — ディスクに平文ファイルを残さない
-
lkr execで環境変数注入 — stdout/ファイル/クリップボードに出さないのが基本ルート - TTYガード — 非対話環境からの生値出力をブロック(AIエージェント対策)
動機: 「.envは.gitignoreしてるからOK」が崩れた
LLMを使う開発では、気づけば .env にAPIキーが5つ、10個と増えていく。
# .env
OPENAI_API_KEY=sk-proj-...
ANTHROPIC_API_KEY=sk-ant-...
この運用が危うい理由は単純で、平文が「そこにある」時点で攻撃面が広がるからだ。
- うっかりコミット(
.gitignoreは人間の運用ミスに弱い) - シェル履歴・コマンドライン引数・ログへの混入
- AIエージェント(IDE/CLI連携)がローカルでコマンドを実行できる運用だと、プロンプトインジェクションで秘密が吸われる筋が生まれる
「1Password CLI や doppler でよくない?」 — 組織のシークレット管理としては正解だ。ただ個人の開発マシンで"LLMキーだけ"を守るには、外部アカウントの契約・チーム前提の設定・常駐デーモンと、運用の前提が増えて重い。lkr は macOS Keychain だけに依存し、追加のサービス契約も常駐デーモンもいらない。
使い方
lkr はmacOS Keychainにキーを暗号化保存する。Service名 com.llm-key-ring のもと、provider:label 形式(例: openai:prod)でキーを管理する。
lkr exec — これが基本(いちばん安全)
$ lkr exec -- python script.py
Injecting 2 key(s) as env vars:
OPENAI_API_KEY
ANTHROPIC_API_KEY
ここが設計の中心。Keychainから取り出したキーを stdoutにもファイルにもクリップボードにも出さず、子プロセスの環境変数としてだけ渡す。
-
openai:*→OPENAI_API_KEY -
anthropic:*→ANTHROPIC_API_KEY - …のように主要プロバイダのenv名にマッピング(OpenAI/Anthropic/Google/Mistral/Cohere/Groq/DeepSeek/xAI…対応一覧はREADME参照)
特定キーだけ入れたいなら -k。
$ lkr exec -k openai:prod -k anthropic:main -- node app.js
そして重要な制約: exec が注入するのは runtime のみ。強権限の admin キーは設計上混ざらない(後述)。
lkr set — キーの保存(値は引数に取らない)
$ lkr set openai:prod
Enter API key for openai:prod: ****
Stored openai:prod (kind: runtime)
- 値はプロンプト入力。CLI引数に取らないので、シェル履歴や
psによるコマンドライン経由の露出を避ける - 上書きは
--force
lkr get — 取得(例外的に人間が手で貼る必要があるとき)
日常運用は exec。get は人間が手で値を貼る必要があるときの例外ルートだ。
$ lkr get openai:prod
Copied to clipboard (auto-clears in 30s)
sk-p...3xYz (runtime) # ← マスク表示(中間部分は隠蔽)
- 画面表示は基本マスク
- クリップボードは30秒後に自動クリア。ただし その30秒の間に別のものをコピーしていたら消さない(SHA-256で一致判定)
どうしても生値が必要なときだけ --show / --plain を使う。ただし後述のTTYガードが効く。
AIエージェント時代の防御: TTYガード
AIエージェントが危ないのは「賢い」からじゃない。「ローカルでコマンドが実行できる」運用にすると、秘密の取り出しが自動化されるからだ。
lkr はここを 非対話(non-TTY)なら制限する ことで潰しにいく。
レイヤー1: stdoutのブロック(--plain / --show)
$ echo | lkr get openai:prod --plain
Error: --plain and --show are blocked in non-interactive environments.
This prevents AI agents from extracting raw API keys via pipe.
Use --force-plain to override (at your own risk).
# exit code 2
判定は IsTerminal(fdレベルの isatty)のみ。CIやTERMみたいな曖昧な環境変数は見ない。信頼性が高い一方で、統合ターミナル(pty)では isatty が true になり得るので、"非対話検出"としてはすり抜ける。だからこそ exec を基本ルートにする設計が効いてくる。
レイヤー2: クリップボードのブロック(pbpaste抜き対策)
非TTYでは get のクリップボードコピーをスキップする。
$ echo | lkr get openai:prod
Clipboard copy skipped (non-interactive environment).
これで「lkr get してから pbpaste」みたいな抜き方を潰す。
レイヤー3: ルートを exec に寄せる
結局、秘密を扱うなら「出力しない」がいちばん強い。exec をデフォルトのワークフローにする。
種別管理: runtime と admin
APIキーは同じ"文字列"でも、扱いを分けるべきものがある。lkr はここを型として分ける。
-
runtime: 推論API呼び出し用(普段使う) -
admin: 使用量確認など強権限(普段は触れない)
$ lkr set openai:prod # デフォルトは runtime
$ lkr set openai:admin --kind admin # 明示的に admin
-
listはデフォルトでruntimeのみ表示 -
gen/execもruntimeのみ対象
この制約があるおかげで、「便利だから全部テンプレ解決」「全部注入」が起きない。
gen はfallback: どうしてもファイルが必要なときだけ
lkr gen は「環境変数が使えない/設定ファイル必須」のツール向けの逃げ道だ。
$ lkr gen .env.example -o .env
Resolved from Keychain:
OPENAI_API_KEY <- openai:prod
ANTHROPIC_API_KEY <- anthropic:main
Kept as-is (no matching key):
DATABASE_URL
Generated: .env (2 resolved, 1 unresolved)
- 生成ファイルは
0600(owner read/write only) -
.gitignoreに含まれていなければ警告 -
adminキーは解決対象外
JSONテンプレは {{lkr:provider:label}} プレースホルダで明示できる。
{
"mcpServers": {
"codex": {
"env": {
"OPENAI_API_KEY": "{{lkr:openai:prod}}"
}
}
}
}
注意: gen はファイルを作るので、そのファイルにアクセスできるプロセスには読まれる。だから普段は exec、gen は最後の手段。
脅威モデル: 守るもの / 守れないもの
セキュリティ系ツールは、守れる範囲が明確じゃないと信用が落ちる。ここまでの各機能を、スコープとして整理しておく。
守る(スコープ内)
| 脅威 | 対策 | 該当機能 |
|---|---|---|
.env に平文キーが常駐 |
Keychainに退避 |
set / get
|
| コマンドライン経由でキーが露出 | プロンプト入力。引数に値を取らない | set |
| クリップボードに残り続ける | 30秒後に自動クリア(SHA-256一致時のみ) | get |
| 非対話環境からの生値吸い出し | TTYガードでstdout/clipboardを制限 | get |
| 強権限キーが普段の運用に混ざる | runtime/admin分離 |
gen / exec
|
| メモリに秘密が残り続ける |
Zeroizing<String> でDrop時ゼロクリア |
全体 |
守れない(スコープ外)
| 脅威 | なぜ守れないか |
|---|---|
| マシンがroot権限で侵害されている | Keychainも同一ユーザーセッション中は開く |
gen で生成したファイルを同一権限プロセスが読む |
ファイルを作った時点で負け筋は残る |
| IDEの統合ターミナル(pty)経由のアクセス |
isatty がtrueになり、TTYガードをすり抜ける |
exec で渡した環境変数を子プロセスがログ出力 |
以降は呼び出し側の責務 |
だから普段のルートを exec に寄せる。出力しない設計が、結局もっとも堅い。
アーキテクチャ
Rustのworkspace構成で、ビジネスロジック(lkr-core)とCLI(lkr-cli)を分離。KeyStore traitでストアを抽象化し、テストは MockStore に差し替えている。取得した秘密値は Zeroizing<String> で保持し、スコープを抜けたらメモリをゼロクリアする。
いまはmacOS Keychainのみだが、Linux libsecret / Windows Credential Manager へ広げられる形になっている。詳細は SECURITY.md を参照。
インストール
cargo install lkr-cli
ソースからビルドする場合:
git clone https://github.com/yottayoshida/llm-key-ring.git
cd llm-key-ring
cargo install --path crates/lkr-cli
macOS専用(Keychain利用)。Rust 1.85+ が必要。
まとめ
| よくある事故 |
lkr の解決 |
|---|---|
.env 平文がリポジトリ周辺に常駐 |
Keychainに保管、必要時のみ取り出し |
| 引数/履歴/ログにキーが混入 |
set はプロンプト入力 |
| クリップボードに残る | 30秒自動クリア(ハッシュ一致時のみ) |
| 非対話実行からの吸い出し | TTYガードでstdout/clipboardを制限 |
| 強権限キーが普段の運用に混ざる | runtime/admin分離 |
| メモリに残り続ける |
zeroize でDrop時ゼロクリア |
「.env に平文キー」は個人開発の定番だった。でもAIエージェントの普及で"ローカル実行の自動化"が現実になり、その定番がリスクに変わった。
lkr はmacOS標準のKeychainを使い、学習コストを最小にしながら、普段のルートを exec に寄せて秘密を外に出さない設計にした。
試してみる:
-
cargo install lkr-cliで入れて、まずlkr set openai:prodでキーを1つ保存する - 普段の
python script.pyをlkr exec -- python script.pyに置き換える -
.envファイルを消す
Discussion