ユーザーの API キーをどこに預かるか問題|WordPress プラグインで AES-256-GCM を使った話

ユーザーの API キーをどこに預かるか問題|WordPress プラグインで AES-256-GCM を使った話 AI
この記事は約9分で読めます。

あなたのプラグインは、預かった API キーを、どこに、どんな形で持っていますか。私の Rapls AI Chatbot は、利用者が入れた OpenAI や Claude の鍵を預かります。先に、たどり着いた設計の核を置きます。平文では持たない。保存は AES-256-GCM で暗号化し、その暗号化に使う鍵は、データベースの中ではなく、wp-config.php の salts から派生させてファイルシステム側に置く。データベースに入るのは暗号文だけです。鍵は課金に直結する資産なので、データベースのダンプ、定期バックアップ、同居する別プラグインからの options 読み取りといった複数の経路で抜かれても、中身が読めない状態にする。それが目的でした。

暗号文はwp_options(データベース)に、鍵の素であるsaltsはwp-config.php(ファイルシステム)にある図

検証環境:Rapls AI Chatbot 1.8.0 / WordPress 7.0(ローカルは 7.0 RC4)/ PHP 8.3.30(Xserver)・8.3.23(ローカル)/ 確認日 2026年5月21日。

最初のプロトタイプは、平文で options に置いていた

正直に白状すると、最初は鍵を平文のまま設定として保存していました。動くからです。WordPress には options という便利な保存先があって、update_option() を1行書けば何でも入ります。でも、ここに API キーをそのまま入れるのは危ない。データベースのダンプを抜かれれば options ごと出ていきますし、自動バックアップの中にも平文のまま残る。同じサイトに同居する別プラグインも、その気になれば get_option() で読めてしまう。漏れる経路は何本もあって、平文だとそのどれを通っても素通しになります。だから、保存の前に必ず暗号化を1枚かませることにしました。この「動くから平文で置いてしまう」は、個人開発でいちばん踏みやすい罠だと思います。締め切り前の自分は、たいてい未来の自分の安全より、今夜動くことを優先します。

AES-256-GCM を選んだのは、改ざんも見たかったから

暗号方式に選んだのは AES-256-GCM です。ただ秘密にするだけなら古くからある AES-256-CBC でも隠せますが、GCM にしたのは認証付き暗号(AEAD)だからでした。GCM は暗号文と一緒に認証タグを吐きます。あとで復号するとき、このタグが合わなければ、暗号文がどこかで書き換えられたと分かる。秘密にすることと、改ざんを検出すること。その両方が1つの方式で手に入るのが理由です。保存時の処理は、流れだけ追えば十分です。二重暗号化を避けるため encg: か enc: で始まっていれば素通しし、毎回違うべき IV(初期化ベクトル)を12バイト新しく作り、aes-256-gcm で暗号化して16バイトの認証タグを受け取り、encg: の後ろへ IV・タグ・暗号文をこの順で連結して Base64 で1本にまとめます。

IV を固定値にすると同じ鍵での安全性が崩れるので、保存のたびに必ず引き直します。接頭辞 encg: を付けておくのは、あとで読み出すときに「これは GCM だ」と一目で判別するためです。

暗号化より、鍵をどこに置くかで一番悩んだ

暗号化そのものより、ずっと悩んだのが、その暗号化に使う鍵をどこに置くか、でした。当たり前ですが、鍵を暗号文と同じデータベースに置いたら、暗号化した意味がありません。ダンプを抜かれたら、鍵も暗号文も一緒に出ていくからです。そこで、専用の定数を新しく置くのはやめて、WordPress がもともと wp-config.php に持っている salts を鍵の素にしました。

wp_salt(‘auth’) は、wp-config.php の AUTH_KEY と AUTH_SALT を内部で結合して返す標準 API です。その出力を素のまま使わず、hash(‘sha256’, …, true) で32バイト固定長に通してから AES-256 の鍵にしています。salts の文字数やバイト構成はインストールごとに違うので、生のまま OpenSSL に渡すと、内部の切り詰めや詰め物の挙動に巻き込まれて不安定になることがある。SHA-256 を必ず1回噛ませて256ビットに固定すれば、どのサイトでも同じ長さの鍵になります。

これで構図が成り立ちます。暗号文は wp_options(データベース)に、鍵はそこから派生する素が wp-config.php(ファイルシステム)にある。データベースのダンプだけを抜かれても、鍵は一緒には漏れません。さらに、salts を変えれば過去の暗号文は二度と復号できなくなる。一見こわい性質ですが、データベースが流出したときに、古い暗号文の解読価値を自分から下げられる、とも言えます。

もう1枚、保険をかけています。AAD(追加の認証データ)にサイトのホスト名を入れました。GCM の認証タグは暗号文だけでなく AAD も一緒に検証するので、データベースを別のドメインに移植すると、ホスト名が変わって認証が通らず、復号できなくなります。盗んだダンプを手元の別サイトに流し込んでも、鍵を再現できないうえに、AAD でも弾かれる。これはあとから足した部分でした。最初は鍵を分けるところまでしか考えていなくて、「ダンプごと別サイトに置かれたら」という意地悪な想像をして、ホスト名束縛を足した。攻撃側の気持ちで一度考えると、こういう一枚が出てきます。

読み出しは、過去の自分の地層を掘る作業だった

復号は1つの関数に集めました。プラグイン全体で復号の実装はここだけで、各プロバイダのアダプタはこれを呼んで復号済みの鍵を受け取ります。これが暗号化より複雑なのは、過去の保存形式が地層のように積み重なっているからでした。まず鍵が sk- や sk-ant-、AIza、sk-or- といった各社の生キーの接頭辞で始まっていれば、まだ暗号化前の値だと判断して素通しします。encg: なら現行の GCM 形式として IV・タグ・暗号文を切り出し、3段階で復号を試します。

本命は派生鍵と AAD あり。だめなら AAD なし(AAD を足す前のデータの救済)、それでもだめなら SHA-256 を噛ませる前の生 salt(派生方式を切り替えた直後のデータの救済)。encg: でなければ enc: や無印の旧 CBC として、やはり派生鍵と生 salt の順に試します。どの段でも失敗したとき、ここが大事なのですが、鍵の本体も暗号文の断片も、一切ログに残しません。空文字を返して、上位に「再入力してください」と促すだけにする。エラーログにうっかり鍵の一部を吐く実装をときどき見かけますが、それをやると、安全のための復号失敗が、かえって漏洩経路になります。

鍵のローテーションは、あえて作らなかった

セキュリティの記事だと鍵のローテーションをどう実装したかになりがちですが、正直に書くと、明示的なローテーションの仕組みは、あえて作っていません。GCM の鍵は、つねに今の wp_salt(‘auth’) から派生したものだけ。新旧2本を持つことはしていません。その代わり、さきほどの「派生鍵」と「生 salt」を両方試す段階フォールバックが、派生方式を切り替えたバージョン1系から2系への移行を、結果的に吸収しました。鍵そのものを回す代わりに派生ロジックを変える、という形の実質的なローテーションは、もう経験しています。

では salts 自体を変えたらどうなるか。過去の暗号文は復号できなくなります。コードはこれをエラーとして検出して、「salt may have changed. Please re-enter your API key.」というログを1回だけ出し、利用者に設定画面から鍵を入れ直してもらう運用にしています。salts を変えるという管理者の操作が、そのまま利用者起点のローテーション動線になっているわけです。鍵を回せばキャッシュ済みの暗号文が全部紙くずになる挙動を、バグではなく仕様として受け入れる。そう割り切ることで、複雑な再暗号化バッチを書かずに済ませました。1人で保守する規模では、動く部品を増やさない判断のほうが、結局は安全でした。

なお、保存したキーは設定画面で二度と平文表示しません。暗号化して持っていても、画面に平文を戻せば覗き見やブラウザのキャッシュから漏れるので、保存済みは「設定済み」のマスク表示だけにして、再入力のときにしか平文を通しません。

次の自分に渡すメモ

同じ判断をまたする日のために、書き残しておきます。API キーのような資産は、動くからといって平文で options に置かない。暗号化は、秘密にするだけでなく改ざんも見たいので、CBC ではなく認証タグの付く GCM を選ぶ。IV は保存のたびに引き直す。そして一番大事なのは、鍵を暗号文と同じデータベースに置かないこと。wp-config.php の salts から派生させれば、ダンプ単体では鍵が漏れません。AAD にホスト名を入れて、別サイトへの移植で復号できないようにしておくと、もう一枚安全になる。復号に失敗したときは、鍵の断片すらログに出さず、黙って再入力に倒す。salts を変えたら過去の暗号文は壊れる、これはバグではなく仕様だと最初に決めておくと、再暗号化の重い仕組みを抱えずに済みます。

鍵は、復号して各プロバイダのアダプタに渡されます。その4社対応の層をどう設計したかは、別記事に書きました。

最後にひとつ。AAD を足したくなったのも、マスク表示にしたのも、ぜんぶ攻撃側の気持ちで自分のコードを眺めたときに出てきました。次に鍵を預かるコードを書くなら、あなたも、まずそこから始めてみてください。

関連記事

AI
この記事を書いた人
rapls

WordPressのプラグインを作っているフリーランスエンジニアです。Web開発はもう6年以上。WordPress.orgで Rapls AI Chatbot、Thanks Mail for Stripe、Rapls PDF Image Creator、Prime Cache の4本を公開し、保守を続けながら、日本語ロケールの翻訳エディター(PTE)も務めています。このブログに書くのは、現場で自分が実際にハマって、調べて、直した話です。

raplsをフォローする
raplsをフォローする

コメント

タイトルとURLをコピーしました