← ブログに戻る

1Passwordを値上げで諦めた私が、SOPS+ageで.envの複数PC共有に着地した話

先週、1Passwordのマスターパスワードがサーバに送られていない仕組みをQiitaに書いた直後に、値上げのお知らせが来ました。個人利用でも月額が上がり、CLIとチーム共有を組み合わせて使うと年間の金額が地味に効いてきます。加えて、私自身は案件先に 1Password を提案する場面が増えてきましたが、最近は価格面で見送られるケースが目立ちます。個人の乗り換え検討と、提案先の代替候補探し、両方の意味で無料のOSS選択肢を掘る必要が出てきました。

そのタイミングで、私の中でずっと保留にしていた問題を決めることにしました。「複数PCの .env をどう共有するか」です。今の私の作業機は2台、開発機と GPU 機が Tailscale で繋がっています。両方から同じリポジトリを触るので、.env を平文で片方に置いて、もう片方に scp する運用がずっと続いていました。1Password CLI で置き換えるつもりだったのを、値上げで白紙に戻したかたちです。

数日かけて Bitwarden・Infisical・dotenvx・SOPS を比較しました。結論から書くと、私は SOPS + age に決めました。この記事はその決定ログと、途中で腑に落ちた「封筒モデル」という公開鍵暗号の見方の記録です。

なぜ SOPS + age を選んだか

比較した4つのうち、Infisical は self-host できる OSS の中では一番 UX が整っていて、実質「OSS版 Doppler」と言える完成度でした。ただ、cron や systemd から secret を引くとき、Infisical のセッショントークンや Service Account の運用が私の環境に合いませんでした。私は harness-ops という自作の自動化基盤を回していて、無人の cron ジョブが常時 API キーを読みに行きます。セッションが expire するタイプの認証はここで詰まります。

Bitwarden (と self-host 版の Vaultwarden) は同じ理由で除外しました。bw CLI のセッショントークンは対話ログイン前提で、cron には向きません。

dotenvx はかなり近い選択肢でした。.env を公開鍵で暗号化して git にコミットする発想は SOPS と同じですし、CLI のインターフェースも綺麗です。ただ、複数recipient (複数PC) を扱う設計が少し泥臭くて、.env.keys を別で持ち回る形になります。私の用途では「PC単位で公開鍵を並べる」設計のほうがローテーションが素直だったので、SOPS を優先しました。

SOPS + age を選んだ決め手は5つあります。無料であること、cron に強いこと (age鍵がディスクにあるだけなのでセッションの概念が存在しない)、git-native で .env.enc をそのままコミットできること、PC 単位のローテーションが原理的に成立すること (公開鍵を追加/削除するだけ)、そしてオフラインで復号できることです。値上げリスクゼロで1Password相当以上のことができる、という判断でした。

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 で丸ごと暗号化すると .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で同じファイルを開ける

ここまでで「SOPS が age を使って .env を暗号化する」までは分かりました。次の疑問は、複数PCで同じ .env.enc をどうやって共有するかです。

素朴に考えると、両方のPCに同じ秘密鍵をコピーしないと復号できないような気がします。ところが SOPS + age では、PC-A と PC-B は異なる秘密鍵を持ったまま、同じ .env.enc を両方から復号できます。ここが最初に理解した瞬間、面白かった箇所です。

仕組みは「封筒モデル」で整理すると腑に落ちます。.env.enc の中身はこうなっています (図1)。

封筒モデル: .env.enc の内部構造。ファイル内に「本体 (対称鍵で暗号化された .env)」「封筒A (公開鍵Aで暗号化された対称鍵)」「封筒B (公開鍵Bで暗号化された対称鍵)」の3ブロックが入っている。PC-Aは秘密鍵Aで封筒Aを開けて対称鍵を取り出し、PC-Bは秘密鍵Bで封筒Bを開けて対称鍵を取り出す。両方の封筒に入っている対称鍵は同じもの。

暗号化する流れはこうなります。SOPS はまずランダムに1個「対称鍵 (マスターキーに相当するもの)」を生成して、その鍵で .env の本体を暗号化します。次に、その対称鍵を公開鍵Aで暗号化したものを「封筒A」、公開鍵Bで暗号化したものを「封筒B」として、本体と一緒にファイルに束ねます。

復号は逆向きに動きます。PC-A は自分の秘密鍵Aで封筒Aを開けて、中に入っていた対称鍵を取り出し、その対称鍵で本体を復号します。PC-B は自分の秘密鍵Bで封筒Bを開けて、同じことをします。封筒Aと封筒Bに入っている対称鍵は「同じ物」なので、両者は同じ本体を読めるわけです。

例えで書くと、こうです。私が会議室 (.env の中身) に鍵をかけたい。参加者はPC-AとPC-B。私は1個のマスターキー (対称鍵) で会議室を施錠して、そのマスターキーを、PC-A宛ての金庫 (公開鍵Aで暗号化) と、PC-B宛ての金庫 (公開鍵Bで暗号化) に入れてドアに貼ります。PC-Aは自分の金庫だけ開けられる、PC-Bも自分の金庫だけ開けられる。でも中身のマスターキーは同じなので、両者とも会議室に入れます。

「同じ秘密鍵を両PCにコピーする」設計より強い理由もここから見えます。PC-Bを紛失したら、.sops.yaml から公開鍵Bを外して sops updatekeys .env.enc を叩けば、封筒Bが物理的に消滅します。PC-Bの秘密鍵は永久にどこにも存在しないファイルを開こうとすることになるので、ローテーションが原理的に成立します。1Passwordのデバイス削除は「サーバ側で拒否」ですが、こちらは「ファイル自体が復号不可能」です。強さのレイヤが違います。

実装 — 両PCで動かすまで

セットアップは初回1回で終わります。両PCそれぞれで次を実行します。

# age をインストール
apt install age sops   # macOS なら brew install age sops

# 鍵を作る (各PCで1回だけ)
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
# 出力される公開鍵 (age1...) をメモしておく

秘密鍵は ~/.config/sops/age/keys.txt に残り、外に出しません。公開鍵だけを控えます。

リポジトリに .sops.yaml を置いて、両PCの公開鍵を並べます。

creation_rules:
  - path_regex: \.env\.enc$
    age: >-
      age1abc...,
      age1xyz...

既存の .env を暗号化して、平文は gitignore に入れます。

sops -e .env > .env.enc
echo ".env" >> .gitignore
git add .env.enc .sops.yaml .gitignore

日常の編集は sops .env.enc を実行するとエディタが起動して、中身は平文で見えます。保存時に自動で再暗号化されます。cron や systemd から使うときは、平文に書き戻さず sops exec-env .env.enc 'npm start' で環境変数として直接プロセスに流せます。

3台目を足すときは、新PCで age-keygen して公開鍵だけを既存PCにコピー、.sops.yaml に追記して sops updatekeys .env.enc を実行します。秘密鍵は生涯そのPCから出ません。

LLM隔離という別の論点

この構成のままだと、私の Claude Code は Bash から sops -d を叩けるので、.env.enc の中身を復号できてしまいます。「暗号化してるのに意味ないのでは?」と一瞬思いました。

ただ、これは私の環境では意図した設計です。harness-ops の cron が API キーを読めなくなると自動化の半分が止まります。Claude Code に復号権を渡さないなら、無人ジョブは動きません。つまり暗号化の目的は「LLM隔離」ではなく、「git漏洩・ディスク盗難・別ユーザからの隔離」と「PC単位のローテーション」だけ、と割り切ることにしました。

本気で LLM 隔離したい鍵 (freee 本番トークン・KDP 認証・Stripe live key など、事故ると金銭直結のもの) は、age-plugin-yubikey で YubiKey に封じる方向です。復号ごとに物理タッチが必要になるので、Claude Code は勝手には触れません。YubiKey プラグインは対話コマンドでは通しやすく、無人 cron は通らない、という切り分けにちょうど合っています。

まとめ

1Passwordの値上げで見直した結果、私は SOPS + age に着地しました。決め手は、値上げに対する耐性、cron 無人ジョブとの相性、複数PCでのローテーションが原理的に成立すること、この3点です。「封筒モデル」で公開鍵暗号の動きが腑に落ちたのが、決定を後押ししました。

先週の1Password Zero-Knowledgeの記事と対で読むと、「重い設計 (SRP・2SKD) が必要な領域」と「sandwich な暗号化で足りる領域」の切り分けが見えてくると思います。私自身は今のところ後者で足りるので、これで進めます。