先週書いた 1Password のマスターパスワードはサーバに届いていない、という記事 の直後に、1Password 値上げのお知らせが来ました。個人利用でも月額が上がり、CLI とチーム共有を組み合わせるとじわじわ効いてきます。
私自身、案件先に 1Password を提案する場面が増えてきましたが、最近は価格面で見送られるケースが目立ちます。個人の乗り換えと、案件で出せる代替提案、両方の意味で「OSS で成立するシークレット管理」を決めることにしました。
数日かけて Bitwarden・Infisical・dotenvx・SOPS を比較しました。結論から書きます。私は SOPS + age に決めました。この記事はその判断ログです。
先に結論だけ
比較した4つのOSS/セルフホスト選択肢を、私の要件で並べるとこうなります。
| ツール | cron 無人ジョブ | 複数PC共有 | 無料 | LLM(AIエージェント)隔離 |
|---|---|---|---|---|
| SOPS + age | ✅ セッション概念なし | ✅ 公開鍵を並べるだけ | ✅ | 🟡 YubiKey プラグインで可 |
| dotenvx | ✅ | 🟡 .env.keys 持ち回り |
✅ | 🟡 |
| Infisical (self-host) | ❌ セッショントークン | ✅ | ✅ | ❌ |
| Bitwarden / Vaultwarden | ❌ bw セッション対話前提 |
✅ | ✅ | ❌ |
私の要件は3つでした。
- 開発機と GPU 機の2台 (Tailscale で繋がっている) で同じ
.envを触りたい -
harness-opsという自作の cron 自動化基盤が 無人で API キーを読む - 案件先にも提案できるよう、無料 で 依存が薄い こと
bw (Bitwarden CLI) と Infisical は cron 相性の時点で外れました。dotenvx は近かったのですが、複数PC (recipient) の扱いが少し泥臭くて、私は SOPS の「PC単位で公開鍵を並べる」設計のほうが素直だと判断しました。
決め手の5つを書き出しておきます。
- 無料 (年間契約に縛られない)
- cron に強い — age 鍵がディスクにあるだけで、セッションの概念が存在しない
-
git-native — 暗号化した
.envをそのままコミットできて、git diffで「どのキーが増えたか」まで読める - PC単位のローテーションが原理的に成立 — 公開鍵を1行追加/削除するだけ
- オフライン復号 — 復号にネット接続がいらない
age と SOPS は別物
実装に入る前に、age と sops が何をやるツールなのかを整理しておきます。
私は最初「同じもの」だと思っていました。比較記事で並べて紹介されることが多いので、混同していました。実際は役割が完全に分かれています。
age は、モダンな GPG です。 作者は Filippo Valsorda、Go 言語チームで crypto ライブラリを主導していた人です。GPG のキーリング・信頼ネットワーク・複雑な設定をすべて捨てて、「公開鍵でファイルを暗号化する」だけに絞ったツールです。公開鍵は age1qyqszq... の62文字1行、依存なし、単一バイナリで動きます。YubiKey や TPM 用のプラグインもあります。
SOPS は、構造化ファイルの「値だけ」を暗号化する層です。 Mozilla 発のツールで、YAML・JSON・.env・ini といったファイルを読み込んで、キーは平文のまま、値だけを暗号化します。実際に暗号化する処理は自分では持たず、外部のバックエンド (age や GPG、AWS KMS、GCP KMS) に投げます。
「値だけ暗号化」が SOPS の核です。試しに .env を age で丸ごと暗号化すると中身は Base64 の塊になって、git diff に何も情報が残りません。SOPS で暗号化するとこうなります。
DATABASE_URL: ENC[AES256_GCM,data:xY8f...]
STRIPE_KEY: ENC[AES256_GCM,data:pQ2m...]
OPENAI_API_KEY: ENC[AES256_GCM,data:zK3v...]
キー名は残るので、git diff で「どのキーが増えた/減った」が読めます。値だけ暗号化されているので、コミットしても安全です。この「構造は追える、中身は守る」というハイブリッドが、他のファイル暗号化ツールにはない SOPS の強みです。
封筒モデル — 秘密鍵を送らずに、両PCで同じファイルを開ける
「値だけ暗号化」までは分かっても、次の疑問が出ます。「PC-A と PC-B は違う秘密鍵を持っているのに、なぜ同じ .env を両方から開けるのか?」
素朴に考えると、両PCに同じ秘密鍵をコピーしないと復号できないような気がします。ところが SOPS + age では、PC-A と PC-B は異なる秘密鍵を持ったまま、同じ .env を両方から復号できます。
仕組みは「封筒モデル」で整理すると腑に落ちます。暗号化された .env の中身はこうなっています (図1)。
SOPS はまずランダムに対称鍵 (マスターキーに相当するもの) を1個生成して、その鍵で本体を暗号化します。次に、その対称鍵を公開鍵Aで暗号化したものを「封筒A」、公開鍵Bで暗号化したものを「封筒B」として、本体と一緒にファイルに束ねます。
復号は逆向きに動きます。PC-A は秘密鍵Aで封筒Aを開けて、中に入っていた対称鍵を取り出し、その対称鍵で本体を復号します。PC-B は秘密鍵Bで封筒Bを開けて同じことをします。封筒A と封筒B に入っている対称鍵は「同じもの」なので、両者は同じ本体を読めます。
「両PCに同じ秘密鍵をコピー」より強い理由
封筒モデルの効果は、ローテーションで一番はっきり出ます。
PC-B を紛失した場合、.sops.yaml から公開鍵Bを外して sops updatekeys を叩けば、封筒Bが物理的に消滅 します。PC-Bの秘密鍵は、永久にどこにも存在しないファイルを開こうとすることになります。復号不能です。
1Password のデバイス削除は「サーバ側で拒否」ですが、こちらは「ファイル自体が復号不可能」です。強さのレイヤが違います。
冒頭で「.sops.yaml は git にコミットしていい」と書いた理由もここです。中に入っているのは公開鍵しかない ので、外に出ても暗号強度は落ちません。むしろレシピの共有そのものが git-friendly になります。
実装 — 両PCで動かすまで
仕組みを踏まえて、手を動かします。セットアップは初回1回で終わります。
1. 両PCで鍵を作る
各PCそれぞれ、以下を実行します。
# インストール
sudo apt install age # macOS: brew install age
# SOPS は release から入れる (最新版は https://github.com/getsops/sops/releases/latest を確認)
SOPS_VERSION=v3.13.2
curl -L -o /tmp/sops "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.amd64"
sudo install /tmp/sops /usr/local/bin/sops
# 鍵ペアを1つ作る (各PCで1回だけ)
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
# 標準出力に公開鍵が出るのでメモ (age1... で始まる62文字)
秘密鍵は ~/.config/sops/age/keys.txt に残り、このPCから外に出しません。共有するのは 公開鍵だけ です。
2. リポジトリに .sops.yaml を置く
両PCの公開鍵を並べます。
# .sops.yaml
creation_rules:
- path_regex: \.env$
age: >-
age1abc...,
age1xyz...
age1abc... が PC-A、age1xyz... が PC-B の公開鍵。この YAML は git にコミットして構いません (前節の理由による)。
path_regex は 暗号化対象のファイル名 にマッチさせます。今回は .env をそのまま暗号化するので \.env$ です。
3. 既存の .env を暗号化する
sops -e -i .env
git add .env .sops.yaml
git commit -m "chore: encrypt .env with SOPS"
-i は in-place オプション。ファイル名は .env のまま、中身だけ暗号化されます。キー名 (DATABASE_URL 等) は平文で残り、値だけが ENC[...] に置き換わります。
暗号化した .env はそのまま git にコミットできます。値は暗号化されているので漏れません。
4. 日常の使い方
編集は sops で開くと、エディタ上は平文で見えます。
sops .env # エディタ起動、保存で自動再暗号化
sops -d .env # 平文を stdout に出したいとき (パイプ用途)
cron や systemd から使うときは、平文ファイルを一切作らず そのままプロセスに渡せます。
sops exec-env .env 'npm start'
sops exec-env .env 'python worker.py'
これで cron 側で平文の .env を経由せずに環境変数を注入できます。
5. 3台目を足すとき
新PCで age-keygen して、出た公開鍵だけを既存PCにコピー。.sops.yaml に1行追記して、
sops updatekeys -y .env
これで新PCも復号できるようになります。秘密鍵は生涯そのPCから出ません。逆に紛失PCを外すときは .sops.yaml から公開鍵を削除して同じコマンドを叩けば、その鍵は復号不能になります。
AIエージェントに sops -d を渡すか — LLM隔離という別の論点
ここまでで、シークレット管理としての SOPS+age は成立しています。が、AIエージェント時代には別の論点があります。
私は Claude Code を Bash 権限つきで動かしています。この構成のままだと、Claude Code は自分で sops -d .env を叩けます。.env の中身を復号できてしまいます。
「暗号化してるのに意味ないのでは?」と一瞬思いました。実は、これは私の環境では意図した設計です。harness-ops の cron が API キーを読めなくなると、自動化の半分が止まります。Claude Code に復号権を渡さないなら、無人ジョブは動きません。
つまり暗号化の目的を最初から切り分けます。
- 今回の SOPS + age の目的: git 漏洩・ディスク盗難・別ユーザからの隔離、PC単位のローテーション
- LLM 隔離は別レイヤの仕事
本気で LLM 隔離したい鍵、つまり 事故ると金銭直結 のもの (freee 本番トークン、KDP 認証、Stripe live key、外部SaaSの管理者API等) は、age-plugin-yubikey で YubiKey に封じる方向を検討しています。復号ごとに 物理タッチが必要 になるので、Claude Code は勝手には触れません。
# YubiKey プラグイン (概念だけ)
age-plugin-yubikey # slot 選んでハードウェア鍵生成
# 出力される recipient (age1yubikey1...) を .sops.yaml に追記
YubiKey プラグインは対話コマンドでは通しやすく、無人 cron は通らない、という切り分けにちょうど合っています。
ここで1点、SOPS の仕様上の制約を書いておきます。同じ .env に age鍵 と YubiKey鍵 の両方を recipient として並べると、リストされているどれか1つの秘密鍵で復号できてしまう ので、AI が触れる age鍵で YubiKey ロックの意図が崩れます。「AI に絶対触らせたくない鍵」と「cron 自動化で使う鍵」を分けるには、ファイル自体を分ける のが正解です。例えば .env は age鍵で復号 (cron用)、.env.critical は YubiKey recipient のみ (対話用) と .sops.yaml の path_regex で切り替えます。
まとめ
1Password の値上げをきっかけに OSS を掘り直した結果、私は SOPS + age に着地しました。決め手は、値上げに対する耐性、cron 無人ジョブとの相性、複数PCでのローテーションが原理的に成立すること、この3点です。「封筒モデル」で公開鍵暗号の動きが腑に落ちたのが、決定を後押ししました。
先週書いた 1Password Zero-Knowledge の記事 と対で読むと、「重い設計 (SRP・2SKD) が必要な領域」と「sandwich な暗号化で足りる領域」の切り分けが見えると思います。私自身は今のところ後者で足りるので、これで進めます。
案件先で「1Password は高い」と言われたときの代替提案としても、この構成はそのまま持ち出せます。無料・OSS・git-native・cron に強い、というカードが同時に切れる選択肢は、案外少ないです。
関連記事
シークレット管理のシリーズです。
- 1Passwordは、あなたのマスターパスワードを一度も受信していません — 商用SaaSの Zero-Knowledge を SRP と 2SKD で解剖
- あなたの.envはDockerイメージに焼き込まれ、誰でも抜き出せる — Dockerレイヤに残るシークレット
- .envは.gitignoreに書いても漏れる:私が実演した3経路 — Shell履歴・CI/CDログ・IDE保存領域
- GitHubから消した秘密は消えていない:削除コミットはForkとSHAで読める — git 履歴の消し方
