Railsのセッション暗号化を量子耐性にした話、あるいはRailsのprivateメソッドを覗き見した3時間
2026.07.11 21:04:41
RailsのセッションストアをPQC化した。「`config.session_store :pqc_cookie_store`って書くだけで、セッションが量子コンピュータにも開けられない暗号で守られる」というやつ。
機能としては地味だ。ユーザーIDとCSRFトークンくらいしか入っていないCookieを暗号化するだけ。「機能よりもgemの人格を確立するフェーズ」。どんなgemなのかという性格を決定させたいなという判断で取り組んだ。
## 「重ねる」か「置き換える」か
実装する前に、ひとつ大きな分岐があった。
Railsのデフォルトのセッションストア(`cookie_store`)は、`secret_key_base`から導いた鍵でAES暗号化している。これをPQCにするとき、2つのやり方がある。
1. Railsの暗号化はそのまま残して、その上にもう一段PQCの暗号化を重ねる(二重暗号化)
2. Railsの暗号化を完全にやめて、PQCに置き換える
1番目は「保険」としては筋が良さそうに見える。AESが将来万が一破られても、PQC層が残っていれば守られる。一見、技術的に堅実な選択だ。
でも、ここで立ち止まって、このgemが誰に向けて作られているかを考え直した。「2030〜2035年頃にPQC対応の義務化ニュースが出て対応に困っている開発者」に使われてほしいのだった。たまたま量子コンピュータに興味を持ったことで熱意を持って勉強と開発を進められているが、そうでなかった世界線のぼくはその頃いくつものRailsアプリを抱えて途方に暮れてしまっていただろう。そのぼくが「助かった」と思うようなものを作りたかった。その別世界線のぼくを抽象化すると「暗号に興味のないRails開発者」や「量子コンピュータの実用化はまだ先だろうと思っている情報セキュリティ担当者」などということになる。
ただ、この「二重暗号化は保険として筋が良い」という直感は、前回作ったハイブリッドKEMの中身を忘れている。pqc_railsのCookie暗号化は、実はもうAESを使っている。X25519(古典)とML-KEMの共有鍵をコンバイナ(HKDF)で結合し、その結合した鍵で最終的にEnvelopeCipher、つまりAES-256-GCMでデータを暗号化している。量子攻撃に対して弱いのはAESの部分ではなく、そこに至るまでの「鍵の合意方法」の部分だ。X25519は単独だとShorのアルゴリズムで解かれてしまうので、ML-KEMと組み合わせて守っている。
一方、Railsのcookie_store標準暗号化は、`secret_key_base`から導出した固定鍵でAES暗号化するだけで、鍵合意(KEM/鍵交換)のステップがそもそも存在しない。同じサーバが暗号化も復号もするので、Shorが狙う「鍵交換の破綻」という脅威自体がRails標準側には最初から無い。
つまりここにRailsのAES暗号化を重ねても、それは独立した固定鍵によるAES-256-GCMをもう一段足すだけの話で、pqc_rails側のEnvelopeCipherと暗号強度としては同じカテゴリの処理を単純に重ねているにすぎない。量子攻撃で本来守るべき対象(X25519の鍵合意部分)には一切関与しないので、量子耐性は1ミリも上がらない。増えるのは、独立した2つの暗号システムを永続的にメンテナンスするコストと、コードが増える分だけ実装バグが紛れ込む余地が広がるリスクだけだ。保険として機能しうる箇所(X25519が将来破られた場合の備え)は、ハイブリッドKEMの中でML-KEMとの組み合わせによって、前回の時点ですでに対処済みだった。
副次的な効果として、説明のシンプルさという面でも2番目には利がある。二重暗号化だと「PQCも使っているけど、実はAESも併用してます」と、説明が一段ねじれる。ターゲットにしている「暗号に興味のない開発者」が欲しいのは、内部実装の説明ではなく「これを入れればPQC対応が完了した」と言い切れる状態だ。
結果的に、2番目(完全に置き換える)を選んだ。
## Railsのprivateメソッドを覗く
問題は、「置き換える」を実際にコードに落とすには、Railsの`ActionDispatch::Session::CookieStore`がどこで暗号化しているかを正確に知る必要があったこと。
最初、ぼくは「`write_session`をオーバーライドすれば良さそう」くらいの当て推量で進めようとしていた。でも、セッション管理はバグると静かに壊れる類のコードだ。「動いているように見えるけど実は別人としてログインできてしまう」みたいな事故は、テストが緑でも気づけないことがある。当て推量で書いて後で気づく、というのが一番怖い。
なので、手元にインストールされていたactionpack(7.1.2と8.1.3、両方)のソースを実際に読んだ。
def set_cookie(request, session_id, cookie) cookie_jar(request)[@key] = cookie end def get_cookie(req) cookie_jar(req)[@key] end def cookie_jar(request) request.cookie_jar.signed_or_encrypted end
これがRailsの実際のコード。`cookie_jar`が`.signed_or_encrypted`を返しているのがポイントで、ここがRailsのAES暗号化への唯一の入り口だった。2つのバージョンを読んで、このメソッド群が完全に同じシグネチャだったことを確認してから、ようやく実装に入った。
def set_cookie(request, _session_id, cookie) cookie[:value] = @encryptor.encrypt(cookie[:value]) request.cookie_jar[@key] = cookie end def get_cookie(request) raw = request.cookie_jar[@key] return nil if raw.nil? @encryptor.decrypt(raw) rescue StandardError nil end
`.signed_or_encrypted`を経由せず、生の`cookie_jar`に対して自分のPQC暗号文を直接書き込む。これでRailsのAES層を完全にバイパスできた。
## セッションストアは「鍵交換」じゃない
もうひとつ、実装しながら気づいたことがある。KEM(Key Encapsulation Mechanism)というと「二者間の鍵交換」を想像しがちだけど、セッションストアの場合、暗号化(Cookie書き込み)と復号(Cookie読み込み)をやるのは同じサーバだ。誰かと鍵を交換しているわけじゃない。
これは実は「KEM-DEM構成のハイブリッド公開鍵暗号」そのものだった。`encrypt`を呼ぶたびに`encapsulate`し直す。長期鍵(secret_key)はサーバに置いたままで変わらないのに、Cookieごとに違うciphertext、違う派生鍵が生まれる。教科書のようなKEMの正しい使い方そのままだ、と後から気づいて少し嬉しかった。
## 正直に書いておきたいこと
このgemに乗り換えると、既存のRailsアプリで運用中のセッション(secret_key_baseで暗号化済み)は、`PqcCookieStore`に切り替えた瞬間に全部無効になる。全ユーザーが再ログインになる。「ゼロダウンタイム」を看板にしているのに、ここは正直に言うと一度だけ痛みが発生する。
これをどう説明するかは今後のREADME・ドキュメントの課題として残しておく。隠さずに書くつもりだ。
## 次に何をやるか
これで「Railsのリクエスト/レスポンスサイクルに触れる暗号化」は一通りできた。次はActiveRecord::Encryption統合。DBに保存されているデータ本体をPQC化する。今回作った`Encryptor`(KEM-DEM構成)のインターフェースをほぼそのまま再利用できる見込みなので、ここで型を固めておいたのは効いてくるはずだ。