テスト用のクレデンシャル、チャットで送ってました — さすがにヤバいので置き場を自作した
テスト用のクレデンシャルが散らかっていた
個人開発でプロジェクトが増えてくると、テスト環境のアカウント情報が散らかってきます。
管理画面のテストユーザー、決済のテストアカウント、外部サービスのサンドボックス用キー。本番のマスター情報ではないけれど、漏れたら困るし、消えても困る。しかも半年後に「あのテストアカウントのパスワード何だっけ」となる種類のものです。
メモアプリに置くのは気が引けるし、ブラウザのパスワード管理に入れると本番アカウントと混ざります。人に一部だけ渡したい場面もあって、そのたびにチャットで送っていました。
そこで、自分用のシークレット管理を建てることにしました。この記事は、その設計で悩んだところの記録です。
「1Password を買え」という正論
設計を詰める段階で、まっとうな指摘が出ました。「1Password を契約すれば月$3で済む話では」というやつです。
これは正しい。実際、保管するだけなら買ったほうが早いし安全です。自作すると設計も運用も自分の責任になります。
それでも自作にしたのは、要件を並べたときに「保管」だけではなかったからでした。
- 長期保管 — 使い捨ての共有リンクでは足りない
- 他の人への共有 — フォルダ単位で渡したい
- 一部だけ開示 — 「このフォルダは見せるけど、この1件は除外」をやりたい
3つ目が特に引っかかりました。招待した相手に見せる範囲を、フォルダ単位より細かく制御したい。ここまで来ると、既製品の使い方を工夫するより、自分の要件に合わせて作ってしまったほうが早いと判断しました。
あわせて、保管対象を最初から絞ることにしました。テスト環境のクレデンシャルと、本番サービスの「自分のテストアカウント」まで。銀行・決済・本番のルート権限は入れないと決めています。自作したものに本番の鍵を預けるのは、リスクの取り方として釣り合いません。
なお、気になるであろう運用コストは 月$3程度でした。1Password とほぼ同額です。内訳は後半に書きますが、金額で見れば自作する意味はありません。差が出るのは別のところでした。
最初に決めないといけないのはドメイン
作り始めてすぐ、順番の問題に気づきました。Passkey を使うなら、ドメインを最初に確定させないといけません。
Passkey(WebAuthn)は、認証器に登録するときに RP ID(Relying Party ID=ドメイン)を一緒に焼き込みます。これが後から変えられません。開発中だからと仮のドメインで作って、あとで本番ドメインに移すと、登録済みの認証器が全部使えなくなります。
普通のWebアプリなら「とりあえず動かして、ドメインは後で」で進められます。Passkey が絡むとそれができない。MVPの段階でも独自ドメインが要る、という制約が最初に来ます。
金庫の所在は、証明書ログでバレる
ドメインを取るとなったとき、最初は手持ちのドメインのサブドメインにしようと考えました。既に持っているものを使えば追加費用はゼロです。
ここで引っかかったのが CT log(Certificate Transparency ログ) でした。
HTTPS 用の証明書を発行すると、その内容は公開ログに記録されます。誰でも検索できます。改ざんされた証明書を検知するための仕組みなので、公開されていること自体は正しい設計です。
問題は、そこに取得したホスト名がそのまま載ることです。
つまり、手持ちのドメインのサブドメインに金庫を置くと、「このドメインの持ち主は、ここに何かを置いている」が公開情報になります。ブログや公開ツールと同じドメイン系列にしていれば、運営者と金庫が紐づきます。攻撃する側から見れば、狙う場所を探す手間が省けるわけです。
そこで、こうしました。
- 別のドメインを新しく取る。手持ちのドメイン系列とは繋げない
- TLD は安いものを選ぶ。年$3程度で取れるものがいくつかあり、この用途には十分
- ホスト名は意味のない英数字にする(ランダムな数文字+TLD、くらいの形)
- サービスの呼び名をドメインに入れない。
vaultやsecretのような単語を含めると、名前だけで用途が分かってしまう
年$3の出費で、公開ログから所在が辿られる線を1本消せるなら安いものです。
暗号化は封筒方式にした
保管するデータの暗号化は、封筒暗号化(envelope encryption) を2段で組みました。
KMS の CMK(1本)
└── ユーザーごとの KEK(鍵を暗号化する鍵)
└── アイテムごとの DEK(データを暗号化する鍵)
アイテム1件ごとに専用の鍵(DEK)を持たせ、その鍵をユーザーの鍵(KEK)で暗号化して保管する形です。データ本体を KMS に投げるわけではないので、KMS の呼び出し回数もコストも抑えられます。
この構造にした理由は、共有を解除するときの挙動です。
一度誰かに見せたアイテムの共有を後から外す場合、単にアクセス権のレコードを消すだけでは足りません。相手が鍵を持ち出していたら、そのまま復号できてしまいます。そこで解除時には、
- 新しい DEK を作る
- その DEK でデータを暗号化し直す
- 古い DEK を破棄する
- アクセス権のレコードを消す
これを1つのトランザクションでまとめて実行します。途中で失敗して「鍵は消えたのにデータは古いまま」という状態にならないようにするためです。
復号した鍵の扱いも決めました。Lambda のメモリ上に置く時間を 60秒まで、保持数の上限を50件にして、破棄するときは明示的にゼロ埋めします。プロセスが使い回されるサーバーレス環境では、消したつもりのものが残りやすいためです。
Cognito の Hosted UI は使わなかった
認証基盤には Cognito を使いましたが、Hosted UI(用意されているログイン画面)は使わず、自前のフロントから WebAuthn を直接叩く形にしました。
Hosted UI は楽な反面、画面が別ドメインに飛びますし、細かい制御が効きません。今回は「どの導線でも必ず Passkey を通す」を要件にしていたので、認証まわりは自分で持ったほうが確実だと判断しました。ブラウザ側は @simplewebauthn/browser を使っています。
Passkey のテストをどうするか
Passkey を必須にすると、E2Eテストが書けなくなる問題にぶつかります。生体認証やPINの入力は自動化できないからです。
これは Playwright の Virtual Authenticator API で解決しました。仮想の認証器をブラウザに登録して、Passkey の登録・認証をテストから実行できます。実際の指紋センサーがなくても、フローとして通せます。
あわせて、dev 環境でも Cognito は実物を使う方針にしました。認証まわりをモックにすると、本番との差分がそのまま事故になるためです。DynamoDB はローカル版で済ませています。
細かいけれど大事な判断
Passkey は2台登録させる
Passkey を1台の端末にしか登録していないと、その端末を失くした時点で自分の金庫に入れなくなります。パスワードと違って「思い出す」ができません。
なので、登録完了画面で2台目の登録を強く促す作りにしました。1台しか登録がない間は赤いバナーを出し続けます。自分専用のツールでも、ここは手を抜くと詰みます。
表示はマスク、コピーしたら30秒で消す
一覧では値をマスクしておき、コピーしたクリップボードは30秒後に自動でクリアします。画面が非表示になった時間を考慮して、タイマーがずれないよう補正も入れました。
監査ログは消せる状態にしない
操作ログの置き場は S3 の Object Lock を有効にしたバケットにしました。書き込んだら期間内は消せない設定です。ログを消せる状態だと、侵入されたときに痕跡ごと消されます。
あわせて、API の実行ロールに削除と更新の権限を渡していません。監査ログに対してできるのは追記だけ、という形にしてあります。権限で縛っておけば、アプリ側にうっかり削除する実装が紛れ込んでも消えません。
コストは月$3程度
作る前の試算は月$3程度でした。CloudTrail のデータイベントに$0.30、ログの保管に$0.20、といった見込みです。
実際に動かしてから請求を見てみると、こうなっていました。
| 項目 | 実額(月) | 備考 |
|---|---|---|
| KMS(鍵の保管) | $1.00 | 鍵1本あたり$1。復号の呼び出しぶんは誤差 |
| Route 53 ホストゾーン | $0.50 | |
| ドメイン(年$3を按分) | $0.25 | |
| Cognito | $0.08 | |
| DynamoDB(読み書き) | $0.01 | |
| CloudFront | $0 | 無料枠の内側 |
| CloudTrail / ログ保管 | ほぼ$0 | 見込んでいたが実際にはほぼ発生せず |
| 合計 | $2弱 |
試算より安く収まりました。差が出たのは、CloudTrail のデータイベントとログの保管を多めに見積もっていたところです。自分しか使わないので、そもそも記録される量が少ない。Lambda と Cognito はほぼ無料枠の内側でした。予算アラートは安全マージンを取って$10に置いています。
ひとつ反省があります。リソースには Project タグを付けていたのに、請求側でそのタグを集計対象として有効にしていませんでした。 AWS のコスト配分タグは、付けるだけでは集計されず、請求ダッシュボードで明示的に有効化する必要があります。しかも有効化しても過去のぶんには遡りません。おかげで、上の数字は使用タイプ別の内訳から見当をつけたものになっています。同じアカウントに複数のプロジェクトを同居させるなら、最初に有効化しておくべきでした。
金額で見れば、1Password とほぼ同じです。 自作したから安くなった、という話にはなりません。差が出るのは、自分の要件に合わせられることと、仕組みが全部見えていることのほうでした。
まとめ
- 保管するだけなら買ったほうが早い。自作の判断は「共有と部分開示をどこまでやりたいか」で決まった
- Passkey の RP ID は後から変えられない。ドメインを最初に確定させる必要がある
- 独自ドメインを取ると、そのホスト名は証明書の公開ログに載る。手持ちのドメイン系列に金庫を置くと、運営者と所在が紐づく。年$3で切り離せるなら安い
- 封筒暗号化にしておくと、共有解除で鍵を作り直して再暗号化するという動きがそのまま書ける
- Passkey を必須にするなら、2台登録と、テスト手段(Virtual Authenticator) を最初に決めておく
- 自分専用ツールほど、自分が入れなくなったときの経路を先に作っておく
保管するものは意識的に絞りました。自作したものに全部を預けるのではなく、失っても致命傷にならない範囲に留めておく。その線引きまで含めての設計だと思っています。
参照リンク
- Certificate Transparency — 証明書の公開ログ
- Web Authentication API (WebAuthn) — RP ID の扱い
- AWS KMS の封筒暗号化
- Playwright — Virtual Authenticator — Passkey の E2E に使う
- SimpleWebAuthn — ブラウザ側の実装に使用