デプロイ先に入れた secret は読み戻せない — 署名鍵の唯一の恒久コピーは削除対象の中にあった
認証サーバーをクラウドから Cloudflare Workers へ移行し終えて、旧環境を退役させる日の作業だった。手順の1番目はバックアップで、旧プロジェクトの Secret Manager に入っていた 11 個の secret をローカルへ退避していく。そのうちの1つが RS256 の署名鍵、1704 バイトの PEM だった。作業ログにこう書いたところで手が止まった。「これが署名鍵の唯一の恒久コピー」。
プロジェクトを消す直前に気づいた
移行そのものは終わっていた。全サーフェスが Workers 側で稼働し、動作確認も済んでいる。あとは旧インフラ — コンテナ、データベース、DNS ゾーン、そしてクラウドプロジェクトそのもの — を消すだけだった。プロジェクトを削除すれば、その中の Secret Manager も一緒に消える。だから先に中身を退避する。ここまでは手順どおりだ。
退避先はローカルの権限 0600 のディレクトリで、これは「消す前に念のため取っておく」つもりの一時置き場だった。ところが署名鍵を書き出した時点で、その一時置き場がこの鍵が存在する唯一の恒久コピーになっていることに気づいた。移行先の Workers Secrets には同じ鍵を投入してある。にもかかわらずコピーが1つしかないのは、そちらが読み出せないからだ。
注入先は書き込めるが読み戻せない
Workers Secrets は wrangler secret putで書き込める。しかし読み出すコマンドはない。ダッシュボードにも secret の「名前」しか出てこない。設定されているかどうかは確認できるが、中身を取り出すことは想定されていない。
これは Workers に固有の話ではない。CI の secrets も同じで、一度登録したら上書きはできても読み戻せない。ローカルの .envはファイルなので読めるが、そのマシンが1台壊れれば同じことになる。共通しているのは、どれも値を注入するための場所であって、値を保管するための場所として設計されていないという点だ。
それまでの自分は、この区別をしていなかった。「secret はデプロイ先に入れてある」で終わっていて、それが保管なのか注入なのかを考えたことがなかった。移行のように環境を作り直す場面が来るまで、両者の違いは表面化しない。作り直すときになって初めて、「どこから持ってくるのか」という問いに答えられないことが分かる。
署名鍵を失うと何が壊れるか
署名鍵が特別なのは、失ったときの復旧手段が存在しないからだ。OpenID Connect の Provider は JWKS で公開鍵を配り、連携している各アプリケーションはそれを取りに来てトークンを検証する。鍵を作り直せば kid も公開鍵も変わる。その瞬間、発行済みのトークンが一斉に検証に落ちる。
移行の cutover でも、切り替え前後の JWKS を byte 単位で比較して差分がないことを継続性の根拠にしていた。あの検証が通ったのは鍵が同一だったからで、裏を返せば鍵を失うことは cutover に失敗するのと同じ結果を招く。しかも今度は戻す先がない。
パスワードなら再設定できる。API トークンなら再発行して差し替えれば済む。署名鍵は「再発行」という操作が、実質的に連携先を全部壊す操作と同義になる。この非対称性が、何を正本として守るかの基準そのものになった。
保管すべきかどうかは、機密度の高さではなく回復不能性で決まる。同じくらい秘密でも、コンソールからワンクリックで再発行できるものと、失ったら二度と手に入らないものは、扱いが違っていい。
何を保管し、何を保管しないか
方針を切り替えて、source of truth を vault 側に置くことにした。デプロイ先は「vault からいつでも再構築できる注入先」と定義し直す。そのうえで、vault に入れるものを次の4種類に限定した。
- 署名鍵・暗号鍵。失うと再発行が「作り直し」にしかならず、連携している側が全部壊れる。
- OAuth の client secret のように、発行時にしか平文が見えない credential。発行側にはハッシュしか残っていないので、控え忘れたら発行し直すしかない。
- インフラ系の API トークン。権限が広く、再発行の影響範囲が読みにくい。
- リカバリーコード、データベースの root credential。人間が一度しか目にしない類のもの。
逆に入れないものも決めた。コンソールからいつでも読み直せるもの・再発行がワンクリックで済むものは入れない。アプリの runtime .envを丸ごと写すこともしない。注入経路は従来どおりにして、vault が持つのは大元だけにする。
最初に検討したのは、その逆の「全部入れておけば安全」という案だった。捨てたのは、写経した値は更新されないからだ。コンソール側で値を回した瞬間に vault の中身は古くなり、しかも誰も気づかない。そして事故が起きた日、人は vault を見に行く。中身が空なら「ここには無い」と正しく分かるが、古い値が入っていると「ここにある」と嘘をつく。入れすぎた vault は、空の vault より危険になる。
正本からデプロイ先への一方向同期
正本を決めたら、そこからデプロイ先へ流す経路を1本に固定する。プロダクトごとに同期スクリプトを置き、vault → デプロイ先の一方向だけを実装した。逆向きのコマンドは用意していない。用意すると、いつか誰かが--reverse を叩いて正本を上書きする。
vault 側の item 名は <product>/<NAME> の形にして、プロダクト名のフォルダに入れる。NAMEの部分はデプロイ先の secret 名と一致させる。同期スクリプトはフォルダ経由で item を引き、名前をそのままデプロイ先の secret 名として使うので、命名規則を守ること自体が同期の前提条件になっている。
# 全 secret を production へ $ scripts/secrets-sync.sh # 個別 secret だけを流し直す $ scripts/secrets-sync.sh production SIGNING_KEY # vault に item がなければ、そこで止まる error: vault に <product>/SIGNING_KEY がない(同期を中断)
重要なのは最後の挙動で、vault に該当の item がなければその場で中断して、それ以降の secret を流さない。部分的に適用して途中で止まったほうが状態が読めなくなるからだ。そして「vault に無い」は、そもそも正本が欠けている状態を意味する。注入先だけを埋めて済ませてはいけない。
このスクリプトを入れたコミットのメッセージは ops: add Bitwarden -> Workers Secrets sync script (vault is source of truth) で、括弧の中がこの変更の全部だった。スクリプト自体は数十行しかない。変わったのは、どちらが正本かという決めごとのほうだ。
投入側のほうが漏れやすい
同期スクリプトを書いたあと、逆方向 — vault へ入れる側 — も専用のコマンドに寄せた。こちらは「人間が値をどこかからコピーしてきて手で流し込む」瞬間なので、実は漏れる経路がいちばん多い。守ったのは3つだけだ。
- 値は stdin からしか受け取らない。引数に置くと ps・シェル履歴・ログのどこかに残る。
- スクリプト内部で jq に渡すときも --arg を使わず環境変数経由にする。--arg はコマンドライン引数なので、実行中は ps から見える。
- 投入後に read-back して byte 一致を検証する。表示するのは一致したかどうかとバイト数だけで、値そのものは出さない。
# 値は stdin から。末尾の改行は command substitution が落とす printf '%s' "$SECRET" | vault-put <product> GOOGLE_CLIENT_SECRET # PEM のようなファイルはそのまま流し込む vault-put <product> SIGNING_KEY < signing-key.pem # 出力は一致の可否とバイト数だけ。値は表示しない OK: <product>/SIGNING_KEY created in folder '<product>', round-trip verified (1704 bytes)
round-trip の検証を入れたのは、PEM のような複数行の値は、途中の改行や末尾の改行が1バイトずれるだけで鍵として読めなくなるからだ。投入した時点では成功したように見えるので、気づくのは復旧が必要になった日になる。その日に初めて壊れていることが分かる保管は、保管していないのと変わらない。だからバイト数まで突き合わせて、その場で確かめる。
コマンドに寄せた副次的な効果として、命名規則が強制されるようになった。フォルダを作り、item 名に <product>/ を付けるのはスクリプト側の仕事になったので、手で作ったときのような揺れが起きない。同期スクリプトはその命名に依存しているから、揺れがそのまま同期の失敗になる構造だった。
添付できない大きなものは、パスフレーズだけ預ける
同じ退役作業では、データベースの最終ダンプも取っている。13 テーブルで 3.6MB のファイルで、これも「唯一のコピー」に該当する。ただし vault の無料プランではファイルを添付できないので、そのままは入らない。
ここは分けて考えることにした。ダンプ本体は GPG の対称暗号化をかけてオブジェクトストレージに置き、vault に入れるのはその復号パスフレーズだけにする。item 名は <product>/ARCHIVE-<name>-passphrase の形にして、鍵と同じ棚に並べる。
この分け方をすると、vault が抱えるのは常に「一番小さくて、一番失いやすいもの」だけになる。容量の制約に引っかからないし、中身が増えすぎて更新漏れが起きる方向にも寄らない。
まとめ
この方針に切り替えてから、運用のルールも短くなった。新しく発行した secret はその瞬間に正本へ入れる。既存のものは、触る機会が来たときに移す。「全部移行する」プロジェクトは立てない — 立てると終わらないまま、移した気になるほうが危ないからだ。
判断の基準は結局のところ、「今この環境を全部消したら、何が二度と手に入らないか」の一問に集約される。移行や退役はその問いを強制的に突きつけてくるだけで、答えが変わるわけではない。環境を消す予定がなくても、答えられない項目があるならそれはもう保管されていない。
なお、ゼロ知識な vault の性質上、マスターパスワードを忘れると全損する。だから最後の1枚だけは紙にして物理的に保管し、2FA を必須にしている。正本の正本は、結局デジタルではない場所に置くことになった。