client_secret 保存時暗号化
OAuth2 クライアントの client.client_secret を Azure Key Vault で保存時暗号化する設計と、既存平文行の移行(backfill)運用。
概要
EcAuth の client テーブルの client_secret は、従来 平文で保存されていた(Google / Auth0 が管理画面から常に取得できるのに合わせた設計)。本機能では、管理画面での再表示要件を維持したまま保存時のセキュリティを高めるため、一方向ハッシュではなく可逆な暗号化で保護する。
- 暗号化方式: Azure Key Vault
CryptographyClient(RSA-OAEP-256)。鍵材は Key Vault の外に出ない。 - 後方互換: 既存の平文行はレガシー経路で従来どおり動作(lazy migration)。新規発行・ローテーション分から順次暗号化。
- 共有ライブラリ化: 汎用の
ISecretProtectorとしてEcAuth.IdpUtilities(NuGet, v2.1.0)に実装。将来open_id_provider.idp_client_secretでも再利用可能。 - 移行: 導入前から存在する平文行は、専用の backfill コマンドで一括暗号化する。
EcAuth は /token のクライアント認証で提示された client_secret を検証する「検証側」であり、かつ管理画面での再表示(要件)も求められる。平文が必要になる場面があるため、復元不能なハッシュではなく可逆暗号化を採用する。
保存エンベロープ形式
保存値は「スキーム付きエンベロープ」で表現する。プレフィックスの有無で暗号化済みかレガシー平文かを判別できるため、後方互換と段階移行を両立する。
| 保存値の形 | 意味 |
|---|---|
kv1:{keyVersion}:{base64(ciphertext)} | Key Vault で暗号化された値。keyVersion は暗号化に使われた鍵バージョン。 |
| プレフィックスの無い文字列 | レガシー / 開発時の平文(後方互換)。 |
復号はエンベロープに埋め込まれた鍵バージョンで行うため、鍵ローテーション後も旧バージョンで暗号化された値を復号できる。暗号化は常に最新バージョンで行う。
アーキテクチャ
flowchart TB
subgraph app["IdentityProvider (App Service)"]
write["発行 / ローテーション
SignupService · Seeder"]
verify["/token 検証
TokenController · B2BPasskeyController"]
prot["ISecretProtector
(KeyVaultSecretProtector)"]
cache["decrypt-once
IMemoryCache"]
end
kv["Azure Key Vault
CryptographyClient (RSA-OAEP-256)"]
db[("client.client_secret
kv1:… / 平文")]
write -->|"ProtectAsync"| prot
verify -->|"VerifyAsync"| prot
prot -->|"暗号化: 公開鍵取得しローカル"| kv
prot -->|"復号: リモート (Decrypt)"| kv
prot --> cache
prot --> db
- 暗号化(Protect):
CryptographyClientが公開鍵を取得してローカルで暗号化する(必要な鍵権限はGet)。結果のKeyIdに含まれる鍵バージョンをエンベロープに埋め込む。 - 復号(Unprotect / Verify): 秘密鍵操作のため Key Vault へのリモート
Decryptを行う。 - decrypt-once キャッシュ: 復号は認証ホットパス(
/token)で発生しうるため、「保存エンベロープ → 復号平文」をIMemoryCacheにキャッシュし、定常状態での Key Vault 往復をほぼゼロにする。ローテーションで暗号文が変われば別キャッシュキーになるため、古い値が誤って使われることはない。
共有ライブラリ(EcAuth.IdpUtilities)
暗号化ロジックは IdpUtilities.Security 名前空間の汎用実装として提供する。ホスト(アプリ)は環境に応じて実装を切り替える。
| 型 | 役割 |
|---|---|
ISecretProtector | 抽象。ProtectAsync / UnprotectAsync / VerifyAsync(定数時間比較)を提供。 |
KeyVaultSecretProtector | 本番実装。エンベロープ生成・decrypt-once キャッシュ・定数時間比較を担い、暗号化/復号は Key Vault に委譲。 |
PlaintextSecretProtector | ローカル開発用のパススルー実装(例外運用)。 |
AddSecretProtection() | DI 拡張。UsePlaintext と KeyVaultKeyId で plaintext / KeyVault を切り替える。 |
アプリ側(Program.cs)は、開発環境または鍵 URI 未設定のとき平文にフォールバックする「保険」を持つ。これはインフラ配線がアプリより遅れても起動が壊れないための安全策で、鍵を設定した時点で自動的に暗号化へ切り替わる。
鍵管理と権限
暗号鍵(RSA 2048)は Terraform(ecauth-infrastructure)で環境ごとの Key Vault に作成する。鍵 URI(非秘密)は App Service の app_settings(ClientSecretProtection__KeyVaultKeyId)に配線する。
| プリンシパル | 鍵権限 | 用途 |
|---|---|---|
| App Service マネージド ID | Get / Encrypt / Decrypt | ランタイムの暗号化・復号検証 |
| Terraform 実行 SP | Get / Create / Delete ほか | 鍵の作成・管理 |
いずれも Terraform モジュールの crypto_key_names を指定した環境でのみ付与される(opt-in)。環境の purge_protection / soft-delete により鍵消失から保護する(production は purge_protection 有効・保持 90 日)。
既存平文行の移行(backfill)
暗号化導入前にシードされた既存クライアントの client_secret は、冪等シーダーが既存行を skip するため平文のまま残る。認証はレガシー経路で動くが暗号化の恩恵を受けないため、専用コマンドで kv1: へ再暗号化する。
- コマンド:
EcAuth/ConsoleAppのbackfill-client-secrets。無印(平文)のclient_secretのみProtectAsyncでkv1:化する。 - 冪等: 既に
kv1:の行は skip。何度実行しても安全。 - dry-run:
--dry-runで対象を列挙するのみ(Key Vault 呼び出し・DB 書き込みなし)。 - 実行:
workflow_dispatchワークフローbackfill-client-secrets.yml(staging / production 選択、既定 dry-run)。 - 値そのものはログに出さず
client_idのみ出力する。
実行順序(重要)
暗号化(kv1:)値を検証できるのは、暗号化対応版アプリが稼働している環境だけ。旧アプリが動いていると kv1: をリテラル比較して認証が壊れる。したがって次の順序を守る。
flowchart LR A["インフラ apply
(鍵 + app_setting)"] --> B["暗号化対応版を
デプロイ"] B --> C["backfill
dry-run → 本実行"] C --> D["verify
(federation / B2B E2E)"]
既存の平文行はレガシー経路で検証され続けるため、デプロイ → backfill の順を守れば無停止で移行できる。
backfill をCIワークフローで実行する際、次の 3 点でつまずきやすい。
- 実行 identity への一時鍵権限: GitHub Actions の実行 SP は Terraform 実行 SP や App Service マネージド ID とは別 object_id で、Key Vault にポリシー未登録のことがある。その場合は
az keyvault set-policy … --key-permissions get encryptで一時付与し、完了後にdelete-policyで戻す(decryptは暗号化のみの backfill には不要)。 dotnet run --no-launch-profile: これを付けないとlaunchSettings.jsonのローカル用接続文字列(Server=127.0.0.1)が CI で export した本番接続文字列を上書きしてしまう。- SQL ファイアウォールの一時許可: .NET から直接 DB 接続するため、ランナー IP を実行前に一時許可し、完了後(
always())に解除する。
適用状況
| コンポーネント | 状態 |
|---|---|
EcAuth.IdpUtilities(ISecretProtector) | v2.1.0 発行済み |
| EcAuth(暗号化本体 + backfill) | main へマージ済み |
| ecauth-infrastructure(staging / production の鍵・権限・app_setting) | apply 済み |
| staging / production の既存平文 secret | backfill で kv1: 化・暗号化後の認証を verify で確認済み |
参照: EcAuthDocs #106(本設計の追跡 issue)。