ActiveRecord::EncryptionをPQC化しようとして、自分の設計を2回壊した話
2026.07.12 10:06:51
pqc_railsの続き。ActiveRecord::Encryption統合をやった。`encrypts :email`と書くだけでDBの中身がML-KEMで守られる、というやつ。
最初に立てた設計は「Cipher層だけ差し替えて、KeyProviderは触らない」というものだった。これは一見正しそうに見える。次あたりに鍵ローテーションをやる予定だから、KeyProviderはそのときまとめて触ればいい、と。
実装に入る前にRailsのソースを読んだら、この前提が崩れた。
## 「鍵ペア」が存在しない場所にKEMを差し込めない
`ActiveRecord::Encryption::Key#secret`を見ると、ただの不透明な文字列が1個入っているだけだった。公開鍵・秘密鍵という「ペア」の概念がそもそも無い。デフォルトの`DerivedSecretKeyProvider`は、設定したパスフレーズからPBKDF2で対称鍵を1個導出して、それを`Key#secret`に詰めているだけ。
つまりKeyProviderを変えずにいると、Cipher層に渡ってくるのは「ただの対称鍵の文字列」だけになる。ML-KEMは公開鍵で暗号化(カプセル化)して秘密鍵で復号する非対称の仕組みなので、鍵ペアが無いとそもそも使う場所が無い。
ここでもう一つ気づいたことがあった。AES-256-GCM自体は、NISTがGroverのアルゴリズムに対する耐性を認めている対称暗号だ。つまり「KeyProviderはそのままで、Cipherだけ差し替える」をやっても、暗号強度の面では実質Railsのデフォルト実装を書き直しただけになる。「ActiveRecord::EncryptionをPQC化した」と言うには、実際にML-KEMの非対称鍵カプセル化が動いていないと嘘になる。
これはこのgemの信頼性に直結する話だと思ったので、いったん手を止めて確認を取った。結果、「単一の鍵ペアを返す最小限のKeyProvider」を今作ることにした。鍵ローテーション(複数世代の鍵を扱う機能)は今回は手を付けず、「鍵ペアを1つ供給する」という土台だけ今作る。この2つは実は別の話だった。
## もう一つの勘違い: `custom_contexts`
最初に想定していたコードはこうだった。
ActiveRecord::Encryption.config.custom_contexts << PqcRails::ActiveRecord::Context
これも実際にソースを読んだら違っていた。`custom_contexts`は`with_encryption_context`がブロックの間だけ使うスレッドローカルなスタックで、永続的な設定の差し替えには使えない。正しい入口は`ActiveRecord::Encryption.configure(key_provider: ..., cipher: ...)`だった。これはRails自身が`config.active_record.encryption.primary_key = ...`みたいな初期化設定を受けて内部で呼んでいるのと同じ公開APIだ。
2つとも、最初の設計を「当て推量」で書いた結果のミスだった。今回は実装に入る前に気づけたので良かったけど、これがもし気づかずにそのまま実装していたら、テストは通るのに実際のRailsアプリには組み込めない、という事故になっていたと思う。
## 動かしてから書く
今回、いつもより先にやったことがある。本物の`ActiveRecord::Base`を素のRubyスクリプトでsqlite3のインメモリDBに繋いで、自前のCipher・KeyProviderをその場で書いて、実際に`encrypts`を動かしてみた。
ActiveRecord::Encryption.configure(key_provider: key_provider, cipher: cipher) Widget.encrypts :secret w = Widget.create!(secret: "hello pqc")
これをやったおかげで、「`Encryptor`は`key.public_tags`というメソッドも呼んでくる」みたいな、ソースを読むだけでは気づきにくい細部を先に潰せた。本番のクラスを書く前に、動くおもちゃを作って触る。当たり前のようだけど、今回は特に効いた。
## KEM-DEM構成、2回目
セッションストアのところで作った「HybridKem(鍵カプセル化)+EnvelopeCipher(AES-256-GCM)」の組み合わせを、ここでもう一度使った。今回はKEMのciphertext(暗号化されたカプセル鍵)を`Message`の`headers[:kem_ct]`という独自のヘッダーに入れて、AES側の暗号文はそのまま`payload`に入れる構成にした。Railsの本家実装(`Aes256Gcm`)がIVと認証タグを別々のヘッダーに入れているのと同じ発想で、設計の一貫性を保てたと思う。
## 次に何をやるか
暗号コアレイヤー・セッション・DBが終わって、「Railsアプリの暗号化で触れる場所は全部PQC化できる」と言い切れる状態になった。次は鍵ローテーションの設計から手をつける予定。