Skip to content

鍵ローテーション ​

ローテーションする鍵の役割は 3 種類で、サイクルもそれぞれ違います:

  • 署名鍵(op.Keyset) — ID トークン、JWT アクセストークン、JARM、userinfo JWT を署名する秘密鍵(既定は ECDSA P-256、OP が RS256 で署名する場合は RSA)。公開側は /jwks に載ります。
  • Cookie 鍵(op.WithCookieKeys) — session、interaction、CSRF の各 cookie を封緘する 32 byte の AES-256-GCM 鍵。
  • inbound 暗号化鍵(op.EncryptionKeyset) — クライアントが暗号化した request object を復号する秘密鍵。公開側は use=enc 鍵として /jwks に載り、outbound 暗号化鍵ではありません。

いずれも、新しい *Provider を構築してアトミックに差し替える形でローテーションします。差し替えは op.Reloadable が担います(再起動なしのリロードを参照)。鍵スライスをその場で書き換える API はありません。

切り替え日を設けずに鍵をローテーションする
署名鍵または cookie 鍵のローテーション手順。新しい鍵を生成し、新鍵を先頭に旧鍵も残した設定で Provider を構築し、ハンドラを不可分に差し替えます。トークンの寿命と配布済みの鍵集合キャッシュが切れるのを待ってから、旧鍵を外します。生成する新しい鍵を作る新しい kid を付ける先頭に置く[新鍵, 旧鍵]先頭が署名、残りは検証用op.New構成全体をもう一度検証するハンドラを差し替える不可分に切り替わるため、中途半端な状態は誰も見ない待つトークンの TTL と各 RP の JWKS キャッシュ期間旧鍵を外す待機が終わるまで外さないそれ以降なら安全
定期ローテーションでは、旧鍵をオーバーラップ期間のあいだ公開したままにし、署名だけをやめます。旧鍵を早く外すと、この検証を壊すおそれがあります。古い鍵集合を保持したままの検証側は、すでに受け取ったトークンを検証する手段を失います。侵害された鍵はインシデント対応上の例外で、速やかに削除します。

署名鍵のローテーション ​

サイクル ​

  • 定期: 60〜90 日に一度。通常のオーバーラップ期間(下記のトークン寿命と RP の JWKS キャッシュ期間)を終えてから退役鍵を外します。
  • 侵害時: 直ちに交換し、侵害されたエントリを除いた Keyset を再構築して JWKS から速やかに削除します。侵害鍵を通常のオーバーラップ期間のために残さず、その鍵で署名されたトークンはインシデント対応と失効の手順に従って扱います。

手順 ​

鍵は 1 本 1 ファイルで置き、OP に読み込ませます。op.LoadKeyset には現行鍵のパスを先頭に、退役鍵のパスをその後に渡します。順序は op.WithKeyset と同じです。

go
// Step 1. 新しい鍵を生成して鍵ディレクトリに置く。
// 例: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256
// それまで現行だったファイルを退役用のファイルにする。

// Step 2. 新鍵を先頭、旧鍵をその後にして読み込む。
ks, err := op.LoadKeyset("/etc/op/keys/signing.pem", "/etc/op/keys/signing-retiring.pem")
if err != nil { /* 旧構成のまま動かし続けてアラート */ }

// Step 3. 任意。OP 自身が退役 kid を信頼する期限を決める。
// 読み込んだ直後の NotAfter はゼロ。「退役した鍵を外すタイミング」参照。
ks[1].NotAfter = time.Now().Add(2*time.Hour + 5*time.Minute)

// Step 4. Keyset をリロードに渡す(次のセクション)。
opts := append(baseOptions, op.WithKeyset(ks))

op.LoadSigningKey(path) は 1 本だけを読み込みます。どちらも PKCS#8 PEM、SEC1 または PKCS#1 の PEM、private JWK(kty が EC または RSA)を受け付け、鍵は ECDSA P-256 か 2048 bit 以上の RSA に限ります。暗号化された PEM、別の曲線、それより短い RSA 鍵、壊れたファイルはエラーになり、エラーにはパスだけを含め、鍵の中身は含めません。kid は、JWK に kid があればその値、なければ公開鍵の RFC 7638 SHA-256 thumbprint です。同じファイルを mount した全レプリカが同じ kid を公開します。LoadKeyset は kid の重複を拒否し、同じ鍵を 2 つのファイルから読んだ場合も重複として扱います。

Keyset の先頭が常に現行の signer で、それ以降は検証専用として JWKS に載ります。JWKS をキャッシュ済みの RP は、次の更新時か、知らない kid に出会った時点で、新しい kid を拾います。キャッシュヘッダの挙動は JWKS エンドポイント を参照してください。

退役エントリの NotAfter は、OP 自身の検証を止める期限です。その時刻以降、OP は /userinfo と /introspect での自分の JWT アクセストークンを含め、その kid を名乗るトークンを拒否し、key.retired_kid_presented を記録します。公開鍵は、Keyset から外して作り直すまで /jwks に残ります。retireAfter には、下で計算する重なり期間の終わりを指定し、ローテーションした時刻そのものにはしないでください。現行エントリの NotAfter は未設定か未来でなければならず、すでに NotAfter に達している場合は op.New が失敗します。

退役した鍵を外すタイミング ​

新しい provider が応答を始めた時点から数えて、次の 2 つが両方とも終わるまで待ちます。

  • 旧鍵が署名したすべてのものの最長の寿命。アクセストークン(既定の WithAccessTokenTTL は 5 分)と ID トークンです。
  • RP 側の JWKS キャッシュの期間。/jwks は max-age=3600, stale-while-revalidate=3600 で返すため、キャッシュが入れ替わるまでに最長で 2 時間かかります。

既定値では 約 2 時間 5 分 です(寿命の長い ID トークンがあれば、その分延びます)。寿命の長いトークンを運用している場合は、その寿命を上乗せしてください。これより早く外すと、その後に /jwks を取得した検証側は、旧鍵が署名したトークンを検証できません。

旧鍵をどれだけ残しておくか
ハンドラを差し替えて旧鍵が署名をやめた時点から始まるタイムライン。旧鍵がすでに署名したトークンは、トークン寿命 1 回分だけ有効です。RP の鍵集合キャッシュが入れ替わるまでには最長 2 時間かかります。旧鍵はこれらがすべて終わるまで Keyset と JWKS に残し、その時点が外せる最も早い時刻です。差し替え後旧鍵が署名をやめる旧鍵が署名したトークンあと 1 トークン寿命のあいだ有効(既定 5 分)RP の鍵集合キャッシュ旧い集合を返し続けることがある(既定で最長 2 時間)Keyset 内の旧鍵上の最も長いバーが終わるまで、公開し信頼したままにする最短で外せる時点
バーの長さは模式です。旧鍵を外してよいのは、最も長いバーが終わってからです。既定値ではキャッシュ期間がそれにあたり、寿命の長いトークンを扱う場合はトークンのバーのほうが長くなることがあります。

ローテーション中であることのシグナル

op.WithJWKSRotationActive(predicate) で Provider に述語を渡します。述語が true の間、/jwks は長い既定キャッシュの代わりに 5 分の Cache-Control: public, max-age=300, must-revalidate を返すので、RP のキャッシュが早く更新されます。重なり期間のあいだ true にしておいてください。op.Reloadable を使う場合、述語はリロードをまたぐフラグや期限を参照できるので、ローテーション時のリロードと、退役鍵を外す後のリロードを 1 つのクロージャで賄えます。JWKS エンドポイント § ローテーション中のキャッシュ制御 を参照。

署名アルゴリズムを切り替える ​

OP が署名に使うアルゴリズムは 1 つです。既定は ES256 で、op.WithSigningAlgorithm(op.AlgRS256) を指定すると RS256 になります。アクティブな鍵(Keyset の先頭)は、そのアルゴリズムに合う種類でなければなりません。つまりアルゴリズムを変えることは鍵の種類を変えることで、通常のローテーションと同じ重なり期間のルールに従います。切り替え前に発行したトークンが検証できるのは、旧い種類の鍵が NotAfter 付きで Keyset に残っている間だけです。

  1. 2048 bit 以上の RSA 鍵を生成します。例: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072。
  2. Keyset の先頭にその鍵を置き、旧い EC 鍵を NotAfter 付きで後ろに残します。署名アルゴリズムと種類が違うエントリには NotAfter が必須で、なければ op.New が失敗します。
  3. op.WithSigningAlgorithm(op.AlgRS256) を加えてリロードします。
  4. NotAfter を過ぎたら、RSA 鍵だけでもう一度リロードします。
go
// 手順 1〜2。RSA 鍵がアクティブで、旧い EC 鍵は検証だけに使います。
ks, err := op.LoadKeyset("/etc/op/keys/signing-rsa.pem", "/etc/op/keys/signing-ec.pem")
if err != nil { /* 旧い構成のまま稼働を続け、アラートを上げる */ }
ks[1].NotAfter = time.Now().Add(2*time.Hour + 5*time.Minute)

// 手順 3。WithKeyset と WithSigningAlgorithm は、1 つのオプションリストに 1 回ずつ渡します。
opts := append(baseOptions, op.WithKeyset(ks), op.WithSigningAlgorithm(op.AlgRS256))
if err := r.Reload(ctx, opts...); err != nil { /* 直前の構成が稼働を続ける */ }

// 手順 4。NotAfter の後に、op.Keyset{rsaEntry} だけでリロードします。

NotAfter は 退役した鍵を外すタイミング の計算で決めます。両方のエントリがある間、/jwks には 2 種類の鍵が載りますが、discovery が広告するのは OP が発行に使うアルゴリズムだけです。最初のリロード以降、id_token_signing_alg_values_supported、userinfo_signing_alg_values_supported、authorization_signing_alg_values_supported、introspection_signing_alg_values_supported は ["RS256"] になります。

旧いアクティブ鍵を除いた Reload は op.ErrReloadIncompatible で拒否されるので、重なり期間を飛ばした切り替えはできません。逆向きの手順で、RS256 の OP を ES256 に戻せます。署名鍵と暗号化鍵は別のものにしてください。WithEncryptionKeyset のエントリが署名鍵と同じ公開鍵を使っていると、op.New が拒否します。RS256 は profile.FAPI2Baseline、profile.FAPI2MessageSigning、profile.FAPICIBA とは併用できません。

RP から見える影響は次のとおりです。

  • ES256 を固定している RPは、discovery を読み直して RS256 を受け付けるようにするまで、切り替え後に発行されたトークンをすべて拒否します。リロードの前に知らせてください。
  • Dynamic Client Registration で id_token_signed_response_alg=ES256 を登録した RPは、OP が実際に発行する alg と食い違う値を持ち続けます。OP はクライアントごとには発行しないため、保存済みの値はそのままですが、その値を変えないメタデータを registration management endpoint へ PUT すると invalid_client_metadata で拒否されます。RP は id_token_signed_response_alg=RS256 を送るか、省略してください(省略した場合は OP が RS256 を補います)。切り替え後の登録は RS256 が既定になります。
  • RSA の公開鍵を公開すると、JOSE ライブラリが RSA の公開鍵を HS256 の秘密鍵として使うことをまだ許している RP が攻撃にさらされます。RP は検証時に期待する alg を固定してください。OP 自身の検証は、JWS ヘッダの alg が鍵のアルゴリズムと等しいことをすでに要求しています。

RS256 の OP に対して RP をテストするには、testkit.WithSigningAlgorithm(op.AlgRS256) でテスト用プロバイダを構築します。RSA 2048 の鍵を生成し、op.WithSigningAlgorithm を op.New に渡します。Provider.SignedJWT は、現在の署名鍵(RS256 か ES256 のどちらか、鍵に合わせた方)でテスト用 fixture に署名し、testkit の kid を付けます。

inbound 暗号化鍵のローテーション ​

op.EncryptionKeyset は、/auth と /par に届く暗号化 request object を復号する OP の inbound 専用 JWE 鍵集合です。outbound 応答のアクティブ鍵を供給するものではありません。outbound JWE 応答はクライアントの JWKS にある use=enc 鍵で暗号化します。

go
encKeys := op.EncryptionKeyset{
    {KeyID: "enc-2026-09", PrivateKey: newEncPriv},
    {KeyID: "enc-2026-05", PrivateKey: oldEncPriv, NotAfter: oldEncExpiry},
}

newProv, err := op.New(
    op.WithIssuer("https://op.example.com"),
    op.WithStore(myStore),
    op.WithKeyset(signingKeys),
    op.WithEncryptionKeyset(encKeys),
    op.WithCookieKeys(cookieKey),
    /* ...残りのオプション... */
)

entry は inbound 復号候補の順序で並び、use=enc として公開されます。kid 付き JWE は一致する live entry を選びます。kidless JWE は保護ヘッダの alg と同じファミリに属する live 鍵へ絞り込まれ、残った候補が op.MaxKidlessEncryptionTrialKeys(既定値 4)を超えると、切り詰めず復号を拒否します。設定したファミリが上限を超える場合は op.New が warning を出すため、RP には kid を含めさせてください。

新旧 entry は、RP の JWKS キャッシュ期間と受理する request object の最長寿命を通して公開します。NotAfter は厳格な cutoff であり、その時刻以降は OP がその鍵を拒否し、公開側も JWKS から外します。overlap が終わったら退役 entry を削除してください。outbound 応答には OP 側の鍵がないため、別途ローテーションするものはありません。

サイクル ​

  • 定期: 四半期ごと(リスクは低めです。cookie は __Host- プレフィックスで保護され、寿命は session の寿命の中に収まります)。
  • 侵害時: 即時。

手順 ​

go
// 新しい 32 byte の鍵を生成する。
newKey := make([]byte, 32)
if _, err := rand.Read(newKey); err != nil { /* abort */ }

// 先頭の鍵で暗号化、それ以降は復号試行に使われる。
op.WithCookieKeys(newKey, oldKey)

鍵をファイルから読む場合は op.LoadSymmetricKeys を使います。渡したパスの順に鍵を返し、先頭のパスが現行鍵です。

go
keys, err := op.LoadSymmetricKeys("/etc/op/keys/cookie.key", "/etc/op/keys/cookie-retiring.key")
if err != nil { /* ... */ }
op.WithCookieKeys(keys...)

各ファイルには、32 byte の raw、64 文字の hex、32 byte 分の base64 のいずれかを入れます。前後の空白は無視します。それ以外の長さは拒否し、openssl rand -hex 16 の出力のような、印字可能 ASCII だけの 32 文字のファイルも対象です。このファイルは 128 bit 分のエントロピーしか持ちません。鍵は openssl rand -hex 32 で生成してください。

WithCookieKeys は復号用に旧鍵を残せるので、有効なセッションはそのまま継続します。session の既定は、アイドル 14 日・絶対 30 日です。oldKey は最長 30 日残してください。それより早く外すと、その鍵で封緘された cookie を持つユーザがログアウトされます。該当するセッションがすべて終わったら、oldKey をスライスから外します。レプリカが複数ある場合は、他のレプリカが開けない cookie を封緘するレプリカが出ないよう、マルチインスタンス展開 の 3 段階の手順でロールアウトしてください。

順序が重要

WithCookieKeys の先頭が現行の暗号化鍵です。旧鍵を先頭にしてしまうと、気付かれないうちに侵害済み鍵を現行扱いに格下げしてしまいます。型ではなく規約での強制なので、ローテーション時はレビューを慎重にしてください。

MFA 暗号化鍵のローテーション ​

WithMFAEncryptionKeys も cookie 鍵と同じ「先頭が現行、以降が過去」の形に従います。32 byte 鍵で TOTP のシークレットを保存時暗号化(AES-256-GCM、subject 識別子を AAD にバインド)します。StepTOTP に専用の EncryptionKey を渡した場合は、そちらが使われます。

go
op.WithMFAEncryptionKeys(currentKey, previousKey)

この鍵も op.LoadSymmetricKeys で読み込めます。previousKey を退役させるのは、永続化済みのすべての TOTP レコードが currentKey で再暗号化されてからです。これはストア側のマイグレーション課題で、ライブラリが自動再暗号化することはありません。

再起動なしのリロード ​

op.NewReloadable は op.New で最初の *Provider を構築し、現行のプロバイダで応答する http.Handler を返します。Reload は置き換え用のプロバイダを構築して差し替えます。構築に失敗したときは、動いているプロバイダがそのまま応答を続けます。起動時もリロード時も同じ関数でオプション一式を組み立てると、リロードのたびに鍵ファイルを読み直すだけで、ほかの設定がずれることがありません。

go
r, err := op.NewReloadable(buildOptions()...)
if err != nil { log.Fatal(err) }
srv := &http.Server{Addr: ":8080", Handler: r}

hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
    for range hup {
        ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
        if err := r.Reload(ctx, buildOptions()...); err != nil {
            slog.Error("reload rejected; the previous configuration keeps serving", "error", err)
        }
        cancel()
    }
}()

ルータにパターンごとに OP をマウントする場合は、r.Routes() の各パターンに r を登録します。プロセスを止めるときは r.Shutdown(ctx) を呼びます。

リロードで引き継がれるものと、組み込み側に求められること:

  • オプションは全部渡す。 Reload は op.New と同じく全オプションを受け取り、差分は受け取りません。

  • 同じストアを使う。 セッション、interaction、ロックアウトカウンタなど永続化済みのレコードが引き継がれるのは、新しいプロバイダが旧プロバイダの書いたストアを読むからです。新しいハンドルで同じデータベースに接続し直すのは問題ありませんが、別のバックエンドを指すとこれらの状態は失われます。

  • issuer とルートは変えられない。 issuer かルート集合が変わると、op.ErrReloadIncompatible をラップしたエラーで拒否します(errors.Is で判定します)。変更したいときはプロセスを再起動してください。ルート集合の変更で拒否された場合、置き換え用のプロバイダはすでに構築済みなので、新しい WithStaticClients の seed はストアに書き込まれています。同じオプションで再起動すれば同じ seed が適用されるため、害はありません。

  • 退役鍵は 2 番目以降に残す。 op.WithCookieKeys と op.WithMFAEncryptionKeys では、新しい鍵を先頭に置き、退役する鍵を後ろに残します。稼働中のセッションや登録済みの TOTP シークレットを封緘した鍵を外すと、それらが読めなくなり、全ユーザがログアウトされ、TOTP の登録もやり直しになります。署名鍵も、旧鍵が署名したトークンが失効するまで Keyset に残してください。

  • 鍵の連続性は強制される。 新しいオプションが、稼働中のプロバイダが払い出したものを失わせる場合、Reload は op.ErrReloadIncompatible で拒否し、稼働中のプロバイダが応答を続けます。

    • WithCookieKeys のリストや WithKeyset が、稼働中の active(先頭)の鍵を含まない場合。KeyID が同じで鍵素材が異なる鍵は、含まれていないものとして扱います。
    • 稼働中の設定が持つ MFA 暗号鍵を 1 つでも外す場合。WithMFAEncryptionKeys で渡した鍵も、StepTOTP.EncryptionKey / EncryptionKeyPrev で渡した鍵も対象です。MFA 鍵を退役させるときは、すべての TOTP レコードを封緘し直してから、一度再起動してください。
    • WithDPoPNonceSource が、稼働中のものと同じインスタンスでない場合。

    そのため、ローテーションは 2 回のリロードで行います。最初のリロードで [新, 旧] を渡し、後のリロードで [新] を渡します。cookie 鍵と署名 keyset では、その時点で新しい鍵が稼働中の active の鍵になっているため、2 回目のリロードが通ります。

  • メモリ上の状態。 DPoP nonce source は、オプション組み立ての外で一度だけ作り、すべてのビルドに同じインスタンスを渡してください。別のインスタンスは、旧 source が発行した nonce が検証できなくなるため拒否されます。op.WithJWKSRotationActive に渡した述語は新しいオプション一式が持つ値になります。リロードをまたぐフラグや期限を参照するクロージャなら、1 つのクロージャが複数回のリロードにまたがって使えます。呼び出しのたびに作り直すクロージャは引き継がれません。

  • Prometheus。 op.WithPrometheus を使っている場合は、旧プロバイダの collector を unregister し、同じ registry に新しい collector を登録します。この間の scrape では issuer の series が見えず、カウンタは 0 から再開します。rate() はこれをカウンタのリセットとして扱います。リロードが拒否されたときは、旧 collector を登録し直します。

  • drain。 差し替えの後、Reload は旧プロバイダで処理中のバックチャネルログアウト配送が終わるまで戻りません。遅い RP がいると Reload が遅れ、次の Reload もその後ろで待ちます。待ち時間は context で区切ってください。旧プロバイダですでに処理中のリクエストは、そのまま旧プロバイダで完了します。

  • Reload を HTTP パスに公開しない。 Reload は OP のセキュリティ設定を作り直します。シグナル、ファイル監視、OP のクライアントから届かない管理用プロセスのいずれかから起動してください。

ソース: examples/53-production-sql は、op.LoadKeyset と op.LoadSymmetricKeys で鍵ディレクトリから署名鍵と cookie 鍵を読み、op.NewReloadable で応答し、SIGHUP でリロードします。cookie 鍵のローテーションは、mv cookie.key cookie-retiring.key で退役させ、新しい cookie.key を書いて kill -HUP するだけです。

ローテーションしないもの ​

  • Issuer。 WithIssuer を変えると、発行済みのすべてのトークン(iss は JWT の一部です)と、すべての RP の discovery キャッシュが無効になります。ローテーションではなくデプロイ扱いです。
  • Keyset 内の KeyID。 いったん JWKS に出した kid は、RP のキャッシュキーになる公開識別子です。別の鍵に同じ kid を再利用すると、古い公開鍵をキャッシュしている RP で検証に失敗します。ローテーションのたびに新しい kid を生成してください。

検証チェックリスト ​

ローテーション後:

  1. curl https://op.example.com/jwks で、新しい kid が出ていて退役した kid がまだ並んでいるか(撤去工程を完了するまでは並んでいるべき)を確認します。
  2. 新しいトークンの kid ヘッダが新鍵を指しているか確認します: echo "<jwt>" | cut -d. -f1 | base64 -d | jq .kid。
  3. 切り替え(cut-over)後に key.retired_kid_presented 監査イベントが新たに発火していないことを確認します。このイベントは、NotAfter を過ぎた kid を名乗る JWS / JWE が届いたときに出るため、NotAfter を設定したエントリだけが対象です。沈黙が成功のシグナルです。
  4. ローテーション前に確立したブラウザセッションで、/auth の認可要求を再開し、再認証なしに進められること(session cookie を復号できること)を確認します。

なぜその場で書き換えず、新しい Provider を作るのか ​

ローテーションは OP のセキュリティ性質を根本から変える操作です。op.New(...) を改めて呼ばせる形にしておくことで:

  • すべてのオプション衝突チェックが再走します(typo を載せたまま動かすことができません)。
  • 検証パスがアトミックに差し替わります(中途半端なローテーション状態が発生しません)。
  • 監督プロセス側のログ / 監査記録に明示的なイベントが残ります(プロセス内部で暗黙に状態変更が走るのとは違います)。

op.Reloadable を使えば、この監督プロセス側の処理は小さく収まります。リロードを直列化し、プロバイダをアトミックに差し替え、issuer やルートの変更、稼働中の cookie 鍵・署名鍵・MFA 鍵の喪失、別の DPoP nonce source への差し替えは拒否します。