pqc_railsにハイブリッド暗号を実装した

2026.07.09 21:32:54

pqc_railsにアルゴリズム抽象化レイヤーを作った話の続き。あの段階では「次はハイブリッド暗号、あるいはOpenSSL 3.x統合のどちらかだな」と思ってたんだけど、実際に手を動かし始めたら、この2つは別の話じゃなかった。同じ実装の表と裏だった。

## なぜハイブリッドにするのか

ML-KEMはNISTが標準化したPQC(耐量子暗号)アルゴリズムだけど、正直に言うと「実績」という意味ではX25519やRSAに何年も劣る。標準化されたのは2024年で、実運用での実績はまだ浅い。

ここで「ML-KEMだけ使えばいいじゃん」とはならない。もし将来ML-KEM自体に未知の脆弱性が見つかったら、PQC対応のつもりで導入した暗号が、ただの脆弱な暗号に成り下がる。だから今、業界の主流は「古典暗号とPQCを両方使って、片方が破られてももう片方が守る」というハイブリッド構成だ。TLS 1.3でChromeが使ってるX25519Kyber768もこの発想。

## 設計の分岐点: OpenSSLに任せるか、自分で結合するか

ここで選択肢が2つあった。

1. OpenSSL 3.xにoqsproviderを差し込んで、ハイブリッドグループをOpenSSL側に丸ごと任せる
2. Rubyの`openssl`標準ライブラリ(古典ECDH側)と、既存のliboqs直接バインディング(PQ側)を、自分でコンバイナを書いて結合する

1番目は最初「OpenSSLに任せたほうが安全そう」という直感があった。TLS実装と同じ枠組みだし、自前で暗号のコンバイナを書くのは普通に考えて怖い。

でも、ここでも「最終的にどこに向かうか」で考え直した。pqc_railsはこれまでliboqsへの直接FFIバインディングという方針でやってきている。ここでoqsproviderという新しい外部依存を増やすと、「liboqsを直接叩く」という軸と「OpenSSLのプロバイダ経由でliboqsを叩く」という軸が両方存在することになる。しかもoqsproviderのインストール状況は環境によってバラバラで、今後この差分に振り回されるリスクがある。

2番目を選んだ。理由は単純で、Rubyの`openssl`はRuby本体に標準添付されていて、追加依存が要らない。試しに手元で確認したら、Ruby 3.4.8 + OpenSSL 3.6.0でX25519もHKDFもAES-256-GCMも全部動いた。

OpenSSL 3.6.0 1 Oct 2025
X25519鍵生成: OK
HKDF: OK
AES-256-GCM: OK

これを確認してから実装に入った。

## コンバイナの怖さ

自分でハイブリッドKEMのコンバイナを書くと言っても、何も考えずに古典共有鍵とPQ共有鍵をくっつければいいわけじゃない。

TLSのハイブリッドKEMは、ハンドシェイク全体のトランスクリプトハッシュで暗号文と最終的な鍵が結びついている。だから単純に「2つの共有鍵を連結するだけ」でも安全。でもpqc_railsはスタンドアロンのライブラリで、そういうトランスクリプトが存在しない。コンバイナ自身が「この共有鍵はどのciphertextと、どのアルゴリズムの組み合わせで作られたものか」を保証する必要がある。

def combine(classical_secret, pq_secret, classical_ciphertext, pq_ciphertext)
  ikm = classical_secret + pq_secret
  info = [
    COMBINER_LABEL,
    @classical.curve,
    @pq.alg_name.to_s,
    classical_ciphertext,
    pq_ciphertext
  ].join("\x00")

  OpenSSL::KDF.hkdf(ikm, salt: "", info: info, length: SHARED_SECRET_LENGTH, hash: "SHA256")
end

`info`に両方のciphertextとアルゴリズム名を入れているのがそこ。これがないと、攻撃者が片方のciphertextを差し替えても誰も気づかない、という穴が理論上残る。

## ECDHを「KEM」として使う

もうひとつ面白かったのが、古典ECDH(X25519)を既存の`Kem`クラスと同じAPI形状(generate_keypair / encapsulate / decapsulate)で包む部分。

ECDHって普通は「鍵交換」で、KEMの「encapsulate/decapsulate」とは語感が違う。でも実は同じ形にできる。encapsulateは「一時鍵ペアを作って、相手の公開鍵とECDHする」、decapsulateは「自分の秘密鍵と、受け取った相手の一時公開鍵でECDHする」。これはRFC 9180 (HPKE)のDHKEMという構成そのもので、知っていたからすぐ実装が決まった。

def encapsulate(public_key)
  peer_key  = OpenSSL::PKey.new_raw_public_key(@curve, public_key)
  ephemeral = OpenSSL::PKey.generate_key(@curve)

  Encapsulation.new(ephemeral.raw_public_key, ephemeral.derive(peer_key))
end

これでPQ側のKEMと完全に対称な形になり、HybridKemが両者を同じように扱える。

## ついでにAES-256-GCMも作った

ハイブリッドKEMで共有鍵が手に入っても、それだけだとデータは暗号化できない。鍵交換と実データの暗号化は別レイヤーの話なので、`EnvelopeCipher`というAES-256-GCMのラッパーも作った。

これも意図して鍵交換と分離した。理由は、将来のActiveRecord::Encryption統合で欲しいのは「鍵を渡したら暗号化/復号してくれるもの」というシンプルなインターフェースで、KEMの中に混ぜ込むと再利用性が落ちる。

## 次に何をやるか

これで暗号コアレイヤーが一通り終わった。次はRackミドルウェアかな。実際にRailsのリクエスト/レスポンスサイクルにこれを組み込む段階になる。正直、ここからが「ゼロダウンタイム導入」という看板の本番だと思っている。