🔑

もう.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エージェント対策)

https://github.com/yottayoshida/llm-key-ring


動機: 「.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 — 取得(例外的に人間が手で貼る必要があるとき)

日常運用は execget は人間が手で値を貼る必要があるときの例外ルートだ。

$ 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)のみ。CITERMみたいな曖昧な環境変数は見ない。信頼性が高い一方で、統合ターミナル(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 はファイルを作るので、そのファイルにアクセスできるプロセスには読まれる。だから普段は execgen は最後の手段。


脅威モデル: 守るもの / 守れないもの

セキュリティ系ツールは、守れる範囲が明確じゃないと信用が落ちる。ここまでの各機能を、スコープとして整理しておく。

守る(スコープ内)

脅威 対策 該当機能
.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 に寄せて秘密を外に出さない設計にした。

試してみる:

  1. cargo install lkr-cli で入れて、まず lkr set openai:prod でキーを1つ保存する
  2. 普段の python script.pylkr exec -- python script.py に置き換える
  3. .env ファイルを消す

https://github.com/yottayoshida/llm-key-ring
crates.io

Discussion