pqc_rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ # Threat Model
2
+
3
+ pqc_railsが前提としている脅威モデルについて説明します。読者に応じて必要なセクションへ直接ジャンプしてください。
4
+
5
+ - [意思決定者向け](#for-decision-makers)
6
+ - [開発者向け](#for-developers)
7
+
8
+ ---
9
+
10
+ ## For Decision Makers
11
+
12
+ ### ハーベスト攻撃とは
13
+
14
+ ハーベスト攻撃(Harvest Now, Decrypt Later)とは、攻撃者が現在の暗号化通信やデータを傍受・保存しておき、将来量子コンピュータが実用化された時点で解読する攻撃を指します。
15
+
16
+ 重要なのは、この攻撃はすでに実行可能だということです。量子コンピュータの実用化を待つ必要があるのは「解読」の段階だけで、「収集」は今この瞬間にも行われる可能性があります。
17
+
18
+ ### なぜ義務化を待ってはいけないか
19
+
20
+ PQC対応を「将来義務化されるから備える」という発想で捉えると、対応の優先度は規制スケジュール任せになります。しかしハーベスト攻撃の性質上、対応が遅れた期間だけ「将来解読されうるデータ」の範囲が広がり続けます。この損失は義務化の期限とは無関係に、今日も積み上がっています。
21
+
22
+ ### どのデータが危険か
23
+
24
+ すべてのデータが同じリスクを持つわけではありません。緊急度を判断する物差しとして、以下の3つの期間で考えるフレームワークがあります(Michele Moscaが提唱し、CRYPTRECガイドライン・金融庁報告書が採用)。
25
+
26
+ - **X**: そのデータを保護し続ける必要がある期間(データの保護期間)
27
+ - **Y**: 暗号方式をPQCへ移行するのに要する期間(移行期間)
28
+ - **Z**: 暗号学的に意味のある量子コンピュータ(CRQC)が実現するまでの期間
29
+
30
+ **X + Y > Z となる場合、そのデータはハーベスト攻撃の脅威に晒されます。** 移行が完了する前に生成されたデータは、生成時点からX年間保護され続ける必要がありますが、その保護期間が終わるより先にCRQCが実現すれば、収集済みの暗号文が解読される可能性があるためです。
31
+
32
+ 例えば、暗号移行に10年(Y)、生成データの保護期間が20年(X)である場合、実質30年間は旧来の暗号方式による保護を維持することになります。その30年のうちにCRQC(Z)が実現すれば、脅威に晒されることになります。
33
+
34
+ | データの性質 | Xの目安 | 緊急度 |
35
+ |---|---|---|
36
+ | 医療記録、法務文書、知的財産に関する通信など、10年20年単位で価値を持つデータ | 大きい | 高 |
37
+ | 数年で無価値化する短期データ | 小さい | 低 |
38
+
39
+ 長期保存データを扱うシステムほど、対応の緊急度は高くなります。Z(CRQCの実現時期)は予測が困難なため、Xが大きいデータについては、Y(移行に要する期間)を短縮すること、すなわちクリプトグラフィック・アジリティ(暗号アルゴリズムを迅速に切り替えられる性質)を高めておくことが有効な対策になります。
40
+
41
+ ### 現在の対応状況
42
+
43
+ 国防・国家機密レベルのデータについては、NSAのCNSA 2.0のように段階的な移行スケジュールがすでに存在します。一方、民間の一般的なWebサービスやRailsアプリケーションが扱うデータについては、対応がまだこれからの段階にあります。
44
+
45
+ ### 規制動向(参考)
46
+
47
+ 「いつまでに対応すべきか」の参考として、各国・各機関が示している具体的な年限を挙げます。pqc_railsやその利用企業がこれらの規制対象に直接該当するとは限りませんが、業界全体の移行圧力がどの速度で強まっているかの目安になります。
48
+
49
+ | 主体 | 年限 | 内容 |
50
+ |---|---|---|
51
+ | フランスANSSI | 2027年〜 | 量子耐性のないセキュリティ製品の認証を停止する方針を表明(欧州初のPQC認証義務化) |
52
+ | 米大統領令14412 (2026-06-22署名) | — | "Securing the Nation Against Advanced Cryptographic Attacks"。連邦システムのPQC移行を義務化 |
53
+ | 米大統領令14413 (2026-06-22署名) | 2026年12月中旬めど | "Ushering in the Next Frontier of Quantum Innovation"。180日以内に国家量子戦略の改定を指示 |
54
+ | 米国防総省 | 2031年まで | "Post-Quantum Cryptography Strategy"。完全PQC移行を義務付け |
55
+ | Microsoft Quantum Safe Program | 2029年まで | 自社製品・サービス群のPQC移行目標年を前倒し |
56
+
57
+ 参考: 国家量子戦略(改定版)は2026年12月中旬に公表予定です(本ドキュメント執筆時点では未公表)。
58
+
59
+ ### CRQC実現時期の観測材料
60
+
61
+ 「量子コンピュータがいつ暗号解読に使えるようになるか」について、決定的な予測は存在しません。以下は2026年7月時点で確認された、実現時期の前後を示唆する動き(ハードウェアの進展、業界の資金調達、基礎研究)の一部です。
62
+
63
+ - Google Quantum AIが強化学習による計算中リアルタイム較正で、超伝導チップ「Willow」の論理エラー率を約20%削減しました(Nature誌、2026年7月)。ただし暗号学的に意味のある量子コンピュータ(CRQC)に必要な桁違いの低エラー率にはまだ遠い、という留保が研究側からも示されています
64
+ - 量子ハードウェア企業への資金調達が継続しています(IQMのNasdaq上場、Qolabの5,420万ドルSeries B、Oratomicの3億ドルSeries A等)
65
+ - Oratomic(中性原子方式)は「約1万〜2万量子ビットでフォールトトレラント量子計算が可能」と主張しており、関連する資源見積もり論文ではECC-256(secp256k1)解読に約2万6000量子ビットで約10日、RSA-2048解読に約10万2000量子ビットで約3ヶ月という試算を示しています。ただしこれは当事者による見積もりであり、査読済み論文でもNIST/CRYPTRECの公式見解でもない点は割り引いて評価する必要があります
66
+ - **256ビット楕円曲線離散対数問題(ECDLP-256)の解読に必要な論理量子ビット数の見積もりが、2026年に入り継続的に下方修正されています**。ECDLP-256は`HybridKem`が古典側で使うX25519の安全性根拠そのものです。
67
+ - Google Quantum AI(2026年3月発表、secp256k1/ECDSA対象): 約1,175論理量子ビット。詳細な回路設計はゼロ知識証明で秘匿する「責任ある開示」の形を取りました
68
+ - EUROCRYPT 2026採択のChevignard等(査読済み): 1,098論理量子ビット
69
+ - 2026年7月15日arXiv投稿のHan Luo等(査読未了): 835論理量子ビット
70
+ - いずれも理論的な量子回路設計・資源見積もりの改善であり、実機でこの規模のエラー訂正済み論理量子ビットが実現された例は現時点で存在しません。しかし見積もりが数ヶ月単位で切り下げられ続けている傾向自体は注視が必要です
71
+ - `HybridKem`はX25519とML-KEMを併用するハイブリッド構成であるため、上記のようにECDLP-256側の見積もりが前進しX25519が将来解読された場合でも、ML-KEM側(NISTがShorのアルゴリズムに対して安全と評価するアルゴリズム)が共有鍵の安全性を独立に担保します
72
+
73
+ これらの動きはいずれも「CRQCの実現が近づいている」ことの直接証拠ではなく、業界の投資・研究ペースを示す間接的な材料として捉えるべきです。過度に危機感を煽る根拠にも、逆に「まだ先の話」と対応を先送りする根拠にも使うべきではありません。
74
+
75
+ ---
76
+
77
+ ## For Developers
78
+
79
+ ### pqc_railsが想定する攻撃シナリオ
80
+
81
+ 1. **通信傍受**: TLS通信路上で暗号化されたセッションデータ・Cookieが第三者に傍受され、保存されます
82
+ 2. **保存データの窃取**: データベースに保存された暗号化カラム(ActiveRecord::Encryption等)が、バックアップ流出やDB侵害を通じて第三者の手に渡ります
83
+ 3. **将来の解読**: 上記1・2で収集された暗号文が、量子コンピュータの実用化後にRSA/ECCベースの鍵交換・署名を解読され、平文化されます
84
+
85
+ pqc_railsは主に2のシナリオ、すなわち保存データに対するPQC対応(KEM-DEM構成による暗号化)を対象にしています。
86
+
87
+ ### 技術的な対応範囲
88
+
89
+ - **鍵交換**: ML-KEM(NIST FIPS 203、512/768/1024の3レベルに対応)です。`PqcRails::Kem`がliboqs経由で提供し、`PqcRails::HybridKem`が古典アルゴリズムとの併用を担います
90
+ - **署名**: ML-DSA(NIST FIPS 204、44/65/87の3レベルに対応)です。`PqcRails::Sig`としてliboqs経由で提供していますが、現時点ではスタンドアロンAPIの提供にとどまり、セッションCookieやActiveRecord::Encryptionへの統合(署名によるデータ完全性検証)には組み込まれていません
91
+ - **構成**: `HybridKem`(X25519によるECDH + ML-KEMのKEM-DEM構成)です。両者の共有鍵とciphertextをHKDF-SHA256で結合し、ML-KEM側に未知の脆弱性が見つかった場合でも古典側の安全性でフォールバックできるようにしています。データ本体の暗号化はAES-256-GCM(`EnvelopeCipher`)が担います。この「連結してからHKDFにかける」コンバイナは自己流ではなく、[RFC 9954](https://www.rfc-editor.org/rfc/rfc9954)(TLS 1.3ハイブリッド鍵共有)が採用する構成と同じで、NIST SP 800-56Cが承認済み手法として挙げている単純連結に基づきます(2026-07-16、RFC本文を確認して検証済み)。`X25519 + ML-KEM-768`という組み合わせ自体も、ブラウザ・CDN(Chrome、Cloudflare等)に加えて、Microsoftが2026年7月14日の月例更新でWindows 11/Windows Server 2025のTLSスタック(Schannel)に`X25519_MLKEM768`ハイブリッドグループを追加したことで、OSレベルの実装でも標準的な組み合わせとして採用されています([出典](https://www.techtimes.com/articles/320559/20260715/post-quantum-cryptography-comes-windows-tls-three-ml-kem-groups-now-configurable.htm))
92
+
93
+ ### 実測値: セッションCookieサイズの増加
94
+
95
+ `HybridKem`のciphertextをCookieに含むため、`PqcCookieStore`はRails標準(AES-256-GCM)よりCookieサイズが大きくなります。開発機での実測値(単純なセッション内容)は次の通りです。
96
+
97
+ | 内容 | pqc_rails(ML-KEM-768、デフォルト) | pqc_rails(ML-KEM-512) | Rails標準 |
98
+ |---|---|---|---|
99
+ | 空セッション | 約1,770 bytes | 約1,300 bytes | 約170 bytes |
100
+ | `user_id` + CSRFトークン程度 | 約1,850 bytes | 約1,400 bytes | 約260 bytes |
101
+
102
+ 固定オーバーヘッドの大半はML-KEM-768のciphertext(1,088byte)がBase64エンコードされた分です。単一Cookieの上限(多くのブラウザで4,096byte)には収まりますが、他のCookie(analytics・A/Bテスト用等)と合算されるリクエストヘッダ全体の上限(多くのサーバ/プロキシで8KB前後)には効いてくる可能性があります。よりサイズを抑えたい場合は`pq_alg_name: :ml_kem_512`を指定してください(NISTセキュリティレベルは768の「レベル3」から512の「レベル1」に下がります)。ActiveRecord::Encryption側(DBカラム)についても同様の増加が生じますが、こちらは未計測です。
103
+
104
+ ### 対応していない範囲(スコープ外)
105
+
106
+ - **TLS層自体のPQC対応**: pqc_railsはアプリケーション層(セッションCookie・DBカラム)の暗号化のみを扱います。TLS 1.3ハンドシェイクのPQC化は対象外で、Webサーバ・ロードバランサ側の設定に依存します。Rubyエコシステム側でも標準ライブラリ全体をPQC-onlyモードで動作させる議論([Ruby Feature #22068](https://bugs.ruby-lang.org/issues/22068)、前提となる`ruby/openssl#894`はマージ済み)が進んでいますが、これはTLS/net-httpレイヤーの話であり、pqc_railsが対象とするアプリケーションデータの暗号化とはレイヤーが異なります
107
+ - **PKI・証明書管理基盤としての機能**: 鍵の発行・失効・証明書ライフサイクル管理・監査ログの提供は対象外です。pqc_railsはRailsアプリ内のセッション・DBカラムの暗号化に特化したライブラリであり、Keyfactor等のエンタープライズPKI基盤の代替ではありません
108
+ - **量子ネイティブな暗号方式**: QKD(量子鍵配送)、ワンショット署名など、量子コンピュータ自体のリソースを前提とする暗号方式は対象外です。pqc_railsが提供するのは古典コンピュータ上で動作し量子コンピュータへの耐性を持つ暗号(PQC)であり、両者は根本的に異なる技術カテゴリです
109
+ - **鍵管理基盤(HSM/KMS等)との連携**: 鍵はデフォルトではRails credentialsまたは環境変数(`KeySource`経由)から読み込みます。`#current_keypair` / `#previous_keypairs` を実装したオブジェクトへの差し替えは可能ですが(将来のHSM/PKCS#11連携を見据えた拡張ポイント)、具体的なHSMやクラウドKMSとの統合実装自体は現時点で提供していません
110
+
111
+ ### コンプライアンス・マッピング
112
+
113
+ 「NIST標準にどう対応しているか」を、監査・調達担当者向けに簡潔にまとめます。
114
+
115
+ | pqc_railsの実装 | 対応するNIST標準 | 備考 |
116
+ |---|---|---|
117
+ | `PqcRails::Kem` / `HybridKem`の鍵交換(KEM) | FIPS 203 (ML-KEM) | 512/768/1024の3レベルに対応。X25519とのハイブリッド構成(`HybridKem`)で運用します |
118
+ | `PqcRails::Sig`の署名 | FIPS 204 (ML-DSA) | 44/65/87の3レベルに対応。現時点ではスタンドアロンAPIのみで、セッション/DB統合には未組み込みです |
119
+ | `EnvelopeCipher`のデータ本体暗号化 | FIPS 197 (AES) / SP 800-38D (GCM) | AES-256-GCMです。NISTはAES-256をGroverのアルゴリズムに対する耐性の観点から量子コンピュータに対しても安全と位置づけています(鍵長が十分であるため) |
120
+
121
+ KEM-DEM構成全体としては、鍵交換をFIPS 203準拠のML-KEMが担い、データ本体をNIST承認済みの対称暗号(AES-256-GCM)が担う、という構成になります。SLH-DSA(FIPS 205)への対応は現時点で行っていません。
122
+
123
+ ### FIPS 203/204のshall要件との対応(監査結果)
124
+
125
+ FIPS 203 §3.3(ML-KEM実装への要求事項)とFIPS 204 §3.6(追加の要求事項)に列挙されているshall要件を、pqc_railsの実装(liboqsへのFFIバインディング層)と突き合わせて監査しました(2026-07-16)。
126
+
127
+ | 要件 | 対応状況 |
128
+ |---|---|
129
+ | K-PKEをスタンドアロンで使わない | 満たしています(トップレベルのML-KEM APIのみ使用) |
130
+ | derandomized版など内部関数への制御されたアクセス | 満たしています(標準APIのみをFFI経由で呼び出し可能にしています) |
131
+ | 共有鍵の以降利用が承認済み手法(NIST SP 800-56C) | 満たしています(`HybridKem`はHKDF-SHA256で導出) |
132
+ | 乱数生成器の強度がパラメータセットの要求以上 | liboqsの内部RNGに委譲しており、pqc_rails自身では検証していません(既知の限界) |
133
+ | Encaps/Decaps・sign/verifyの入力長チェック | 満たしています(公開鍵・秘密鍵・暗号文・署名すべての長さをliboqs呼び出し前に検証します) |
134
+ | 中間値の破棄 | Rubyレイヤーでの明示的なメモリワイプは未実装です。GCおよびFFIのfree()はゼロクリアを保証しないため、既知の限界として扱っています |
135
+ | 浮動小数点演算を使わない | 該当しません(pqc_railsは暗号計算そのものを実装しておらず、liboqsのC実装に委譲しています) |
136
+
137
+ **乱数生成器の強度・中間値の破棄**については、pqc_rails自身のコードでは検証しきれない範囲(liboqsのC実装内部)であるため、「満たしている」と断定せず、現時点での既知の限界として明記します。
138
+
139
+ ### 参照仕様
140
+
141
+ - NIST FIPS 203 (ML-KEM)
142
+ - NIST FIPS 204 (ML-DSA)
143
+ - [CRYPTREC暗号技術ガイドライン(耐量子計算機暗号)2024年度版](https://www.cryptrec.go.jp/report/cryptrec-gl-2007-2024.pdf) — X+Y>Zフレームワーク、クリプトグラフィック・アジリティの定義の出典
144
+ - [金融庁「預金取扱金融機関の耐量子計算機暗号への対応に関する検討会 報告書」(2024年11月)](https://www.fsa.go.jp/singi/pqc/houkokusyo.pdf) — X+Y>Zの具体例、クリプト・インベントリの構成例の出典
145
+ - [国家量子戦略(改定版)](https://www.whitehouse.gov/presidential-actions/2026/06/ushering-in-the-next-frontier-of-quantum-innovation/) — 米大統領令14413に基づき2026年12月中旬公表予定です。本ドキュメント執筆時点(2026年7月)では未公表のプレースホルダー参照です
146
+ - [ISO/IEC 18033-2:2006/Amd 2:2026](https://www.iso.org/standard/86890.html) (Classic McEliece)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require_relative "../cipher"
5
+ require_relative "key_provider"
6
+
7
+ module PqcRails
8
+ module ActiveRecord
9
+ # ActiveRecord::Encryptionのデフォルトcontextに、PqcRails::Cipherと
10
+ # PqcRails::ActiveRecord::KeyProviderを設定する。
11
+ #
12
+ # 注意: ActiveRecord::Encryption.config.custom_contexts はwith_encryption_context用の
13
+ # スレッドローカルな一時上書きスタックで、永続的な差し替えには使えない。
14
+ # 永続的な差し替えの正しい入口は ActiveRecord::Encryption.configure(...)。
15
+ module Context
16
+ module_function
17
+
18
+ def install!(pq_alg_name: PqcRails::Cipher::DEFAULT_PQ_ALG_NAME,
19
+ primary_key: existing_config_value(:primary_key),
20
+ deterministic_key: existing_config_value(:deterministic_key),
21
+ key_derivation_salt: existing_config_value(:key_derivation_salt))
22
+ ::ActiveRecord::Encryption.configure(
23
+ key_provider: PqcRails::ActiveRecord::KeyProvider.new,
24
+ cipher: PqcRails::Cipher.new(pq_alg_name: pq_alg_name),
25
+ primary_key: primary_key,
26
+ deterministic_key: deterministic_key,
27
+ key_derivation_salt: key_derivation_salt
28
+ )
29
+ end
30
+
31
+ # ActiveRecord::Encryption.configure(...)は呼ぶたびにprimary_key/deterministic_key/
32
+ # key_derivation_saltを常に上書きする(未指定ならnilで巻き戻す)ため、install!を呼ぶ前に
33
+ # bin/rails db:encryption:init等で既に設定されていた値を、明示的な指定が無い限り
34
+ # 引き継ぐ。config側の値は未設定だとErrors::Configurationを送出しうるので握りつぶす。
35
+ def existing_config_value(name)
36
+ ::ActiveRecord::Encryption.config.public_send(name)
37
+ rescue ::ActiveRecord::Encryption::Errors::Configuration
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../key_source"
5
+
6
+ module PqcRails
7
+ module ActiveRecord
8
+ # ActiveRecord::Encryption::KeyProvider互換。HybridKemの鍵ペアを返す実装。
9
+ #
10
+ # 既存のActiveRecord::Encryption::KeyProvider(Key#secretが単一の不透明な文字列)は
11
+ # 公開鍵/秘密鍵という「鍵ペア」の概念を持たないため、ML-KEMを使う余地が無い。
12
+ # そのためKeyProvider自体を独自実装し、`key.secret`がPqcRails::HybridKem::Keypair
13
+ # (公開鍵・秘密鍵のペア)を返すようにしている。
14
+ #
15
+ # 鍵のソース(環境変数→Rails credentials→decode)はKeySourceに一本化されており、
16
+ # セッション用の鍵とは異なるENV変数名/credentialsキーを渡すことで用途ごとに鍵を分離する。
17
+ #
18
+ # 鍵ローテーションは#decryption_keysが現行鍵に続けて旧鍵世代を返すことで対応する
19
+ # (ActiveRecord::Encryption::Cipher互換の#decryptは配列の各鍵を順に試すため、
20
+ # PqcRails::Cipher#decryptは変更不要)。旧鍵はPQC_RECORD_PREVIOUS_KEYS(ENV)または
21
+ # pqc_record_previous_keys(credentials)から読み込む。
22
+ class KeyProvider
23
+ ENV_VAR = "PQC_RECORD_KEY"
24
+ PREVIOUS_ENV_VAR = "PQC_RECORD_PREVIOUS_KEYS"
25
+ CREDENTIALS_KEY = :pqc_record_key
26
+ PREVIOUS_CREDENTIALS_KEY = :pqc_record_previous_keys
27
+
28
+ Key = Struct.new(:secret) do
29
+ def id
30
+ Digest::SHA1.hexdigest(secret.public_key).first(4)
31
+ end
32
+
33
+ def public_tags
34
+ @public_tags ||= ::ActiveRecord::Encryption::Properties.new
35
+ end
36
+ end
37
+
38
+ def initialize(key_source: default_key_source)
39
+ @key_source = key_source
40
+ end
41
+
42
+ def encryption_key
43
+ @encryption_key ||= build_key(@key_source.current_keypair)
44
+ end
45
+
46
+ def decryption_keys(_encrypted_message)
47
+ [encryption_key] + @key_source.previous_keypairs.map { |keypair| build_key(keypair) }
48
+ end
49
+
50
+ private
51
+
52
+ def default_key_source
53
+ PqcRails::KeySource::EnvCredentials.new(
54
+ env_var: ENV_VAR, previous_env_var: PREVIOUS_ENV_VAR,
55
+ credentials_key: CREDENTIALS_KEY, previous_credentials_key: PREVIOUS_CREDENTIALS_KEY,
56
+ label: "record"
57
+ )
58
+ end
59
+
60
+ def build_key(keypair)
61
+ Key.new(keypair).tap do |key|
62
+ key.public_tags.encrypted_data_key_id = key.id if ::ActiveRecord::Encryption.config.store_key_references
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PqcRails
4
+ # NIST標準化アルゴリズムのシンボル名(:ml_kem_512等)とliboqs側のアルゴリズム名文字列
5
+ # ("ML-KEM-512"等)を対応づけるレジストリ。
6
+ #
7
+ # Kem/Sigはliboqsの生の文字列を直接渡すこともできるが(liboqsが対応する任意のアルゴリズムを
8
+ # 使える柔軟性は維持する)、このレジストリを経由してシンボルで指定すれば、
9
+ # liboqs側の命名に依存しない安定したAPIになる。
10
+ # 将来 ActiveRecord::Encryption と統合する際、設定ファイルやモデルにliboqsの生文字列を
11
+ # 書かせるのではなく、このシンボルを書かせることを想定している。
12
+ #
13
+ # 使い方:
14
+ # PqcRails::Kem.new(:ml_kem_512)
15
+ # PqcRails::Sig.new(:ml_dsa_44)
16
+ module Algorithms
17
+ class UnknownAlgorithmError < PqcRails::Error; end
18
+
19
+ Algorithm = Struct.new(:name, :liboqs_name, :family, :security_level, :status, keyword_init: true)
20
+
21
+ KEMS = {
22
+ ml_kem_512: Algorithm.new(
23
+ name: :ml_kem_512, liboqs_name: "ML-KEM-512", family: :kem, security_level: 1, status: :recommended
24
+ ),
25
+ ml_kem_768: Algorithm.new(
26
+ name: :ml_kem_768, liboqs_name: "ML-KEM-768", family: :kem, security_level: 3, status: :recommended
27
+ ),
28
+ ml_kem_1024: Algorithm.new(
29
+ name: :ml_kem_1024, liboqs_name: "ML-KEM-1024", family: :kem, security_level: 5, status: :recommended
30
+ )
31
+ }.freeze
32
+
33
+ SIGS = {
34
+ ml_dsa_44: Algorithm.new(
35
+ name: :ml_dsa_44, liboqs_name: "ML-DSA-44", family: :sig, security_level: 2, status: :recommended
36
+ ),
37
+ ml_dsa_65: Algorithm.new(
38
+ name: :ml_dsa_65, liboqs_name: "ML-DSA-65", family: :sig, security_level: 3, status: :recommended
39
+ ),
40
+ ml_dsa_87: Algorithm.new(
41
+ name: :ml_dsa_87, liboqs_name: "ML-DSA-87", family: :sig, security_level: 5, status: :recommended
42
+ )
43
+ }.freeze
44
+
45
+ module_function
46
+
47
+ def find_kem(name)
48
+ KEMS.fetch(name.to_sym)
49
+ rescue KeyError
50
+ raise UnknownAlgorithmError, "unknown KEM algorithm: #{name.inspect}. Known: #{KEMS.keys.join(', ')}"
51
+ end
52
+
53
+ def find_sig(name)
54
+ SIGS.fetch(name.to_sym)
55
+ rescue KeyError
56
+ raise UnknownAlgorithmError, "unknown SIG algorithm: #{name.inspect}. Known: #{SIGS.keys.join(', ')}"
57
+ end
58
+
59
+ # liboqsに渡すべきアルゴリズム名文字列を解決する。
60
+ # Stringが渡された場合はliboqsの生の名前としてそのまま通す(後方互換・将来の未登録アルゴリズム用)。
61
+ # Symbolが渡された場合はレジストリ経由でliboqs名に変換する。
62
+ def resolve_kem_name(name)
63
+ return name if name.is_a?(String)
64
+
65
+ find_kem(name).liboqs_name
66
+ end
67
+
68
+ def resolve_sig_name(name)
69
+ return name if name.is_a?(String)
70
+
71
+ find_sig(name).liboqs_name
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PqcRails
4
+ # 2つのバイト列を [4byte長][本体][4byte長][本体] で1本のバイト列にまとめる、その逆を行う。
5
+ # HybridKemの鍵/ciphertextパック(古典+PQ)と、KeyManagerの鍵シリアライズ(公開鍵+秘密鍵)で
6
+ # 同じ処理が必要になるため共通化している。
7
+ module BlobPacking
8
+ module_function
9
+
10
+ def pack(first, second)
11
+ [first.bytesize].pack("N") + first + [second.bytesize].pack("N") + second
12
+ end
13
+
14
+ def unpack(blob)
15
+ first_length = blob[0, 4].unpack1("N")
16
+ first = blob[4, first_length]
17
+
18
+ second_offset = 4 + first_length
19
+ second_length = blob[second_offset, 4].unpack1("N")
20
+ second = blob[(second_offset + 4), second_length]
21
+
22
+ [first, second]
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require_relative "hybrid_kem"
5
+ require_relative "envelope_cipher"
6
+ require_relative "session/encryptor"
7
+
8
+ module PqcRails
9
+ # ActiveRecord::Encryption::Cipher互換のインターフェースを持つ独立したクラス。
10
+ # ActiveRecord::Encryption.configure(cipher: PqcRails::Cipher.new, key_provider: ...)で
11
+ # デフォルトのAES-256-GCM単体実装を完全に置き換える。
12
+ #
13
+ # ActiveRecord::Encryptionの`key:`は「鍵そのもの」をそのまま渡してくる設計
14
+ # (KeyProvider#encryption_key.secretの値がそのまま渡る)。本実装ではKeyProvider側で
15
+ # `key.secret`がPqcRails::HybridKem::Keypair(公開鍵・秘密鍵のペア)を返すように
16
+ # 揃えてあるので、ここではそのKeypairを直接受け取れる。
17
+ #
18
+ # 内部的にはPhase 2と同じKEM-DEM構成(HybridKem+EnvelopeCipher)。
19
+ # Session::Encryptorを再利用しないのは、ARはCipher層に外から`key:`を渡す設計のため
20
+ # 鍵の持ち方が異なるから(Session::Encryptorは自分でKeypairを保持する)。
21
+ class Cipher
22
+ DEFAULT_PQ_ALG_NAME = PqcRails::Session::Encryptor::DEFAULT_PQ_ALG_NAME
23
+
24
+ def initialize(pq_alg_name: DEFAULT_PQ_ALG_NAME)
25
+ @pq_alg_name = pq_alg_name
26
+ end
27
+
28
+ def encrypt(clear_text, key:, deterministic: false)
29
+ raise ArgumentError, "PqcRails::Cipher does not support deterministic encryption" if deterministic
30
+
31
+ HybridKem.open(@pq_alg_name) do |hybrid|
32
+ encap = hybrid.encapsulate(key.public_key)
33
+ envelope = EnvelopeCipher.new(encap.shared_secret).encrypt(clear_text)
34
+
35
+ ::ActiveRecord::Encryption::Message.new(payload: envelope).tap do |message|
36
+ message.headers[:kem_ct] = encap.ciphertext
37
+ end
38
+ end
39
+ end
40
+
41
+ def decrypt(encrypted_message, key:)
42
+ keypairs = key.is_a?(::Array) ? key : [key]
43
+ last_error = nil
44
+
45
+ HybridKem.open(@pq_alg_name) do |hybrid|
46
+ keypairs.each do |keypair|
47
+ return decrypt_with(hybrid, encrypted_message, keypair)
48
+ rescue OpenSSL::Cipher::CipherError, PqcRails::Error, ArgumentError, TypeError => e
49
+ last_error = e
50
+ end
51
+ end
52
+
53
+ raise ::ActiveRecord::Encryption::Errors::Decryption, last_error&.message
54
+ end
55
+
56
+ def key_length
57
+ EnvelopeCipher::KEY_LENGTH
58
+ end
59
+
60
+ def iv_length
61
+ EnvelopeCipher::IV_LENGTH
62
+ end
63
+
64
+ private
65
+
66
+ def decrypt_with(hybrid, encrypted_message, keypair)
67
+ kem_ciphertext = encrypted_message.headers[:kem_ct]
68
+ raise PqcRails::Error, "message is missing the kem_ct header" if kem_ciphertext.nil?
69
+
70
+ shared_secret = hybrid.decapsulate(kem_ciphertext, keypair.secret_key)
71
+ EnvelopeCipher.new(shared_secret).decrypt(encrypted_message.payload)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PqcRails
4
+ # gem全体の設定。Railsアプリ側からは以下のように使う想定:
5
+ #
6
+ # # config/initializers/pqc_rails.rb
7
+ # PqcRails.configure do |config|
8
+ # config.liboqs_path = "/usr/local/lib/liboqs.dylib"
9
+ # end
10
+ class Configuration
11
+ # liboqsの共有ライブラリへのパス。
12
+ # 未設定の場合、環境変数 LIBOQS_PATH、それも無ければOS標準の場所を仮定する。
13
+ attr_accessor :liboqs_path
14
+
15
+ def initialize
16
+ @liboqs_path = ENV["LIBOQS_PATH"] || default_liboqs_path
17
+ end
18
+
19
+ private
20
+
21
+ # OS別のliboqsデフォルトパス。
22
+ # あくまで「よくあるインストール場所」の当て推量であり、
23
+ # 本番運用では明示的に liboqs_path を設定することを強く推奨する。
24
+ def default_liboqs_path
25
+ case RbConfig::CONFIG["host_os"]
26
+ when /darwin/
27
+ "/usr/local/lib/liboqs.dylib"
28
+ when /linux/
29
+ "/usr/local/lib/liboqs.so"
30
+ else
31
+ raise PqcRails::Error,
32
+ "liboqs_path is not set and no default is known for this OS (#{RbConfig::CONFIG['host_os']}). " \
33
+ "Set PqcRails.configure { |c| c.liboqs_path = '...' } explicitly."
34
+ end
35
+ end
36
+ end
37
+
38
+ class << self
39
+ def configuration
40
+ @configuration ||= Configuration.new
41
+ end
42
+
43
+ def configure
44
+ yield(configuration)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require_relative "length_validation"
5
+
6
+ module PqcRails
7
+ # 古典的なECDH(X25519)を「KEM」のAPI形状(generate_keypair/encapsulate/decapsulate)で
8
+ # 包んだクラス。RFC 9180(HPKE)のDHKEMと同じ考え方:
9
+ #
10
+ # encapsulate: 一時鍵ペアを生成し、一時秘密鍵と相手の公開鍵からECDHで共有鍵を導出する。
11
+ # ciphertextは一時鍵の公開鍵そのもの。
12
+ # decapsulate: 自分の秘密鍵とciphertext(相手の一時公開鍵)からECDHで同じ共有鍵を導出する。
13
+ #
14
+ # これにより PqcRails::Kem (liboqs) と同じインターフェースで古典鍵を扱えるようになり、
15
+ # HybridKem側で両者を対称に組み合わせられる。
16
+ #
17
+ # 使い方:
18
+ # dh_kem = PqcRails::DhKem.new
19
+ # keypair = dh_kem.generate_keypair
20
+ # encap = dh_kem.encapsulate(keypair.public_key)
21
+ # shared = dh_kem.decapsulate(encap.ciphertext, keypair.secret_key)
22
+ # shared == encap.shared_secret # => true
23
+ class DhKem
24
+ include LengthValidation
25
+
26
+ Keypair = Struct.new(:public_key, :secret_key)
27
+ Encapsulation = Struct.new(:ciphertext, :shared_secret)
28
+
29
+ KEY_LENGTH = 32
30
+
31
+ attr_reader :curve
32
+
33
+ def initialize(curve = "X25519")
34
+ @curve = curve
35
+ end
36
+
37
+ def length_public_key = KEY_LENGTH
38
+ def length_secret_key = KEY_LENGTH
39
+ def length_ciphertext = KEY_LENGTH
40
+ def length_shared_secret = KEY_LENGTH
41
+
42
+ def generate_keypair
43
+ key = OpenSSL::PKey.generate_key(@curve)
44
+ Keypair.new(key.raw_public_key, key.raw_private_key)
45
+ end
46
+
47
+ def encapsulate(public_key)
48
+ validate_length!(public_key, length_public_key, "public_key")
49
+
50
+ peer_key = OpenSSL::PKey.new_raw_public_key(@curve, public_key)
51
+ ephemeral = OpenSSL::PKey.generate_key(@curve)
52
+
53
+ Encapsulation.new(ephemeral.raw_public_key, ephemeral.derive(peer_key))
54
+ end
55
+
56
+ def decapsulate(ciphertext, secret_key)
57
+ validate_length!(ciphertext, length_ciphertext, "ciphertext")
58
+ validate_length!(secret_key, length_secret_key, "secret_key")
59
+
60
+ own_key = OpenSSL::PKey.new_raw_private_key(@curve, secret_key)
61
+ ephemeral_pub = OpenSSL::PKey.new_raw_public_key(@curve, ciphertext)
62
+
63
+ own_key.derive(ephemeral_pub)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require_relative "length_validation"
5
+
6
+ module PqcRails
7
+ # AES-256-GCMによるエンベロープ暗号化。HybridKem等で導出した32バイトの共有鍵を使って
8
+ # 実際のデータ(平文)を暗号化/復号する。「鍵交換」と「データ暗号化」を分離するのは、
9
+ # 後でActiveRecord::Encryption統合(Phase 3)で「鍵→暗号化結果」のインターフェースとして
10
+ # そのまま使えるようにするため。
11
+ #
12
+ # 暗号文のフォーマット: IV(12byte) + 認証タグ(16byte) + 暗号文本体
13
+ # この3つを1本のバイト列にまとめて返すことで、DBカラム等に1カラムでそのまま保存できる。
14
+ #
15
+ # 使い方:
16
+ # cipher = PqcRails::EnvelopeCipher.new(key) # 32バイトの鍵
17
+ # encrypted = cipher.encrypt("secret")
18
+ # cipher.decrypt(encrypted) # => "secret"
19
+ class EnvelopeCipher
20
+ include LengthValidation
21
+
22
+ CIPHER_NAME = "aes-256-gcm"
23
+ KEY_LENGTH = 32
24
+ IV_LENGTH = 12
25
+ TAG_LENGTH = 16
26
+
27
+ def initialize(key)
28
+ validate_length!(key, KEY_LENGTH, "key")
29
+ @key = key
30
+ end
31
+
32
+ def encrypt(plaintext, associated_data: nil)
33
+ cipher = OpenSSL::Cipher.new(CIPHER_NAME)
34
+ cipher.encrypt
35
+ cipher.key = @key
36
+ iv = cipher.random_iv
37
+ cipher.auth_data = associated_data if associated_data
38
+
39
+ ciphertext = cipher.update(plaintext) + cipher.final
40
+
41
+ iv + cipher.auth_tag + ciphertext
42
+ end
43
+
44
+ def decrypt(encrypted, associated_data: nil)
45
+ iv = encrypted[0, IV_LENGTH]
46
+ tag = encrypted[IV_LENGTH, TAG_LENGTH]
47
+ ciphertext = encrypted[(IV_LENGTH + TAG_LENGTH)..]
48
+
49
+ cipher = OpenSSL::Cipher.new(CIPHER_NAME)
50
+ cipher.decrypt
51
+ cipher.key = @key
52
+ cipher.iv = iv
53
+ cipher.auth_tag = tag
54
+ cipher.auth_data = associated_data if associated_data
55
+
56
+ cipher.update(ciphertext) + cipher.final
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module PqcRails
6
+ module Ffi
7
+ # liboqsのGeneric KEM APIへの低レベルFFIバインディング。
8
+ module Kem
9
+ extend FFI::Library
10
+
11
+ ffi_lib PqcRails.configuration.liboqs_path
12
+
13
+ class Struct < FFI::Struct
14
+ layout(
15
+ :method_name, :pointer,
16
+ :alg_version, :pointer,
17
+ :claimed_nist_level, :uint8,
18
+ :ind_cca, :bool,
19
+ :length_public_key, :size_t,
20
+ :length_secret_key, :size_t,
21
+ :length_ciphertext, :size_t,
22
+ :length_shared_secret, :size_t,
23
+ :length_keypair_seed, :size_t,
24
+ :length_encaps_seed, :size_t,
25
+ :keypair_derand, :pointer,
26
+ :keypair, :pointer,
27
+ :encaps_derand, :pointer,
28
+ :encaps, :pointer,
29
+ :decaps, :pointer
30
+ )
31
+ end
32
+
33
+ attach_function :OQS_KEM_new, [:string], :pointer
34
+ attach_function :OQS_KEM_free, [:pointer], :void
35
+ attach_function :OQS_KEM_keypair, [:pointer, :pointer, :pointer], :int
36
+ attach_function :OQS_KEM_encaps, [:pointer, :pointer, :pointer, :pointer], :int
37
+ attach_function :OQS_KEM_decaps, [:pointer, :pointer, :pointer, :pointer], :int
38
+ end
39
+ end
40
+ end