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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module PqcRails
6
+ module Ffi
7
+ # liboqsのGeneric SIG(署名)APIへの低レベルFFIバインディング。
8
+ # PqcRails::Ffi::Kem と対になる、署名アルゴリズム版。
9
+ module Sig
10
+ extend FFI::Library
11
+
12
+ ffi_lib PqcRails.configuration.liboqs_path
13
+
14
+ # liboqs/include/oqs/sig.h の OQS_SIG 構造体に対応。
15
+ # liboqs 0.15.0の実物ヘッダ(/usr/local/include/oqs/sig.h)で確認済みのフィールド順序。
16
+ #
17
+ # KEMのOQS_KEM構造体との違い:
18
+ # - euf_cma / suf_cma / sig_with_ctx_support という3つのbool値が追加されている
19
+ # - length_ciphertext / length_shared_secret は無く、代わりに length_signature がある
20
+ # - keypair_derand に相当するderandomized版は無い(2026-06-20時点のヘッダには存在しない)
21
+ # - sign / verify に加えて、コンテキスト文字列付きの
22
+ # sign_with_ctx_str / verify_with_ctx_str が追加で存在する
23
+ class Struct < FFI::Struct
24
+ layout(
25
+ :method_name, :pointer,
26
+ :alg_version, :pointer,
27
+ :claimed_nist_level, :uint8,
28
+ :euf_cma, :bool,
29
+ :suf_cma, :bool,
30
+ :sig_with_ctx_support, :bool,
31
+ :length_public_key, :size_t,
32
+ :length_secret_key, :size_t,
33
+ :length_signature, :size_t,
34
+ :keypair, :pointer,
35
+ :sign, :pointer,
36
+ :sign_with_ctx_str, :pointer,
37
+ :verify, :pointer,
38
+ :verify_with_ctx_str, :pointer
39
+ )
40
+ end
41
+
42
+ # OQS_SIG *OQS_SIG_new(const char *method_name);
43
+ attach_function :OQS_SIG_new, [:string], :pointer
44
+
45
+ # void OQS_SIG_free(OQS_SIG *sig);
46
+ attach_function :OQS_SIG_free, [:pointer], :void
47
+
48
+ # OQS_STATUS OQS_SIG_keypair(const OQS_SIG *sig, uint8_t *public_key, uint8_t *secret_key);
49
+ attach_function :OQS_SIG_keypair, [:pointer, :pointer, :pointer], :int
50
+
51
+ # OQS_STATUS OQS_SIG_sign(const OQS_SIG *sig, uint8_t *signature, size_t *signature_len,
52
+ # const uint8_t *message, size_t message_len, const uint8_t *secret_key);
53
+ # 引数は6個: sig, signature, signature_len(出力, size_t*), message, message_len, secret_key
54
+ # signature_len は size_t* (出力引数)。FFIでは :pointer として渡し、
55
+ # 呼び出し側で FFI::MemoryPointer#read_ulong 等で読み出す必要がある。
56
+ attach_function :OQS_SIG_sign,
57
+ [:pointer, :pointer, :pointer, :pointer, :size_t, :pointer],
58
+ :int
59
+
60
+ # OQS_STATUS OQS_SIG_verify(const OQS_SIG *sig, const uint8_t *message, size_t message_len,
61
+ # const uint8_t *signature, size_t signature_len, const uint8_t *public_key);
62
+ # 引数は6個: sig, message, message_len, signature, signature_len, public_key
63
+ attach_function :OQS_SIG_verify,
64
+ [:pointer, :pointer, :size_t, :pointer, :size_t, :pointer],
65
+ :int
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "rails/generators"
5
+ require_relative "../../hybrid_kem"
6
+ require_relative "../../session/encryptor"
7
+ require_relative "../../session/key_manager"
8
+
9
+ module PqcRails
10
+ module Generators
11
+ # `rails generate pqc_rails:install` で実行される。
12
+ # config/initializers/pqc_rails.rbを生成し、セッション暗号化用のHybridKemキーペアを
13
+ # 生成してRails credentialsに保存する。
14
+ class InstallGenerator < Rails::Generators::Base
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ desc "Generates the pqc_rails initializer and a PQC session key in Rails credentials."
18
+
19
+ def create_initializer
20
+ copy_file "initializer.rb", "config/initializers/pqc_rails.rb"
21
+ end
22
+
23
+ def add_session_key_to_credentials
24
+ encoded_key = PqcRails::Session::KeyManager.encode(generated_keypair)
25
+
26
+ credentials.change do |tmp_path|
27
+ data = YAML.safe_load(tmp_path.read, permitted_classes: [Symbol], aliases: true) || {}
28
+ data["pqc_session_key"] = encoded_key
29
+ tmp_path.write(data.to_yaml)
30
+ end
31
+
32
+ say_status :credentials, "Added pqc_session_key to Rails credentials"
33
+ end
34
+
35
+ private
36
+
37
+ def generated_keypair
38
+ @generated_keypair ||= PqcRails::HybridKem.open(PqcRails::Session::Encryptor::DEFAULT_PQ_ALG_NAME) do |hybrid|
39
+ hybrid.generate_keypair
40
+ end
41
+ end
42
+
43
+ def credentials
44
+ Rails.application.credentials
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # config/application.rb (または config/initializers/session_store.rb) で
4
+ # 以下を設定すると、セッションがPQC(ML-KEM + X25519のハイブリッド)で暗号化されます。
5
+ #
6
+ # config.session_store :pqc_cookie_store
7
+ #
8
+ PqcRails.configure do |config|
9
+ # config.liboqs_path = "/usr/local/lib/liboqs.dylib"
10
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require_relative "blob_packing"
5
+ require_relative "dh_kem"
6
+ require_relative "kem"
7
+
8
+ module PqcRails
9
+ # 古典ECDH(DhKem)とPQ KEM(liboqs経由のKem)を組み合わせたハイブリッドKEM。
10
+ #
11
+ # 量子コンピュータがPQ側を破ったとしても古典側が安全なら(あるいはその逆でも)
12
+ # 全体の共有鍵は安全、という「移行期間中の保険」を提供するのが目的。
13
+ # NIST標準化されたML-KEMはまだ実運用歴が浅いため、実績のあるX25519と組み合わせることで
14
+ # 「PQアルゴリズム自体に未知の脆弱性が見つかった場合」のリスクを下げる。
15
+ #
16
+ # コンバイナの方式: 古典側の共有鍵とPQ側の共有鍵を連結し、両者のciphertext
17
+ # (encapsulateの出力)をHKDFのinfoにバインドした上でHKDF-SHA256にかけて
18
+ # 32バイトの最終共有鍵を導出する。ciphertextをバインドするのは、
19
+ # 通常のTLSハンドシェイクのようなトランスクリプトハッシュによる結合が無い
20
+ # スタンドアロン用途のため、コンバイナ自体に結合の安全性を持たせる必要があるため。
21
+ #
22
+ # この「連結してからHKDFにかける」方式は自己流ではなく、RFC 9954(TLS 1.3ハイブリッド鍵共有、
23
+ # 旧draft-ietf-tls-hybrid-design)が採用する"concatenation approach"と同じ構成であり、
24
+ # NIST SP 800-56Cが承認済みアルゴリズムと非承認アルゴリズムのハイブリッド共有鍵生成における
25
+ # 承認済み手法として単純連結を挙げている。RFC 9954はこの構成が「dual-PRFコンバイナ」に対応し、
26
+ # ハッシュ関数がdual-PRFであるという仮定の下で安全性が証明されているとしている([BINDEL]論文)。
27
+ # なお、RFC 9370(IKEv2の複数鍵交換)はSKEYSEED(n) = prf(SK_d(n-1), SK(n) | Ni | Nr)という
28
+ # 逐次的なPRF連鎖(cascade)を採るため、本実装とは別のコンバイナ方式である点に注意
29
+ # (2026-07-16、一次資料読解セッションでRFC本文を確認して検証済み)。
30
+ #
31
+ # 使い方:
32
+ # PqcRails::HybridKem.open(:ml_kem_512) do |hybrid|
33
+ # keypair = hybrid.generate_keypair
34
+ # encap = hybrid.encapsulate(keypair.public_key)
35
+ # shared = hybrid.decapsulate(encap.ciphertext, keypair.secret_key)
36
+ # shared == encap.shared_secret # => true
37
+ # end
38
+ class HybridKem
39
+ Keypair = Struct.new(:public_key, :secret_key)
40
+ Encapsulation = Struct.new(:ciphertext, :shared_secret)
41
+
42
+ COMBINER_LABEL = "pqc_rails-hybrid-kem-v1"
43
+ SHARED_SECRET_LENGTH = 32
44
+
45
+ attr_reader :pq_alg_name, :classical_curve
46
+
47
+ def initialize(pq_alg_name, classical_curve = "X25519")
48
+ @pq_alg_name = pq_alg_name
49
+ @classical_curve = classical_curve
50
+ @classical = DhKem.new(classical_curve)
51
+ @pq = Kem.new(pq_alg_name)
52
+ @freed = false
53
+ end
54
+
55
+ def generate_keypair
56
+ ensure_not_freed!
57
+
58
+ classical_keypair = @classical.generate_keypair
59
+ pq_keypair = @pq.generate_keypair
60
+
61
+ Keypair.new(
62
+ BlobPacking.pack(classical_keypair.public_key, pq_keypair.public_key),
63
+ BlobPacking.pack(classical_keypair.secret_key, pq_keypair.secret_key)
64
+ )
65
+ end
66
+
67
+ def encapsulate(public_key)
68
+ ensure_not_freed!
69
+
70
+ classical_public_key, pq_public_key = BlobPacking.unpack(public_key)
71
+ classical_encap = @classical.encapsulate(classical_public_key)
72
+ pq_encap = @pq.encapsulate(pq_public_key)
73
+
74
+ Encapsulation.new(
75
+ BlobPacking.pack(classical_encap.ciphertext, pq_encap.ciphertext),
76
+ combine(classical_encap.shared_secret, pq_encap.shared_secret,
77
+ classical_encap.ciphertext, pq_encap.ciphertext)
78
+ )
79
+ end
80
+
81
+ def decapsulate(ciphertext, secret_key)
82
+ ensure_not_freed!
83
+
84
+ classical_ciphertext, pq_ciphertext = BlobPacking.unpack(ciphertext)
85
+ classical_secret_key, pq_secret_key = BlobPacking.unpack(secret_key)
86
+
87
+ classical_shared = @classical.decapsulate(classical_ciphertext, classical_secret_key)
88
+ pq_shared = @pq.decapsulate(pq_ciphertext, pq_secret_key)
89
+
90
+ combine(classical_shared, pq_shared, classical_ciphertext, pq_ciphertext)
91
+ end
92
+
93
+ def free
94
+ return if @freed
95
+
96
+ @pq.free
97
+ @freed = true
98
+ end
99
+
100
+ def self.open(pq_alg_name, classical_curve = "X25519")
101
+ hybrid = new(pq_alg_name, classical_curve)
102
+ yield hybrid
103
+ ensure
104
+ hybrid&.free
105
+ end
106
+
107
+ private
108
+
109
+ # 古典側とPQ側の共有鍵を連結し、両者のciphertextとアルゴリズム名をinfoに
110
+ # バインドした上でHKDF-SHA256にかけ、最終的な共有鍵を導出する。
111
+ def combine(classical_secret, pq_secret, classical_ciphertext, pq_ciphertext)
112
+ ikm = classical_secret + pq_secret
113
+ info = [
114
+ COMBINER_LABEL,
115
+ @classical.curve,
116
+ @pq.alg_name.to_s,
117
+ classical_ciphertext,
118
+ pq_ciphertext
119
+ ].join("\x00")
120
+
121
+ OpenSSL::KDF.hkdf(ikm, salt: "", info: info, length: SHARED_SECRET_LENGTH, hash: "SHA256")
122
+ end
123
+
124
+ def ensure_not_freed!
125
+ raise PqcRails::Error, "this PqcRails::HybridKem instance has already been freed" if @freed
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "algorithms"
4
+ require_relative "length_validation"
5
+ require_relative "ffi/kem"
6
+
7
+ module PqcRails
8
+ # liboqsのGeneric KEM APIをRubyらしく包んだクラス。
9
+ #
10
+ # 使い方:
11
+ # kem = PqcRails::Kem.new(:ml_kem_512) # またはliboqsの生の名前 "ML-KEM-512"
12
+ # keypair = kem.generate_keypair
13
+ # encap = kem.encapsulate(keypair.public_key)
14
+ # shared = kem.decapsulate(encap.ciphertext, keypair.secret_key)
15
+ # shared == encap.shared_secret # => true
16
+ #
17
+ # kem.free を呼ぶか、ブロック形式(PqcRails::Kem.open)を使うことで
18
+ # Cレベルのメモリを確実に解放できる。
19
+ class Kem
20
+ include LengthValidation
21
+
22
+ Keypair = Struct.new(:public_key, :secret_key)
23
+ Encapsulation = Struct.new(:ciphertext, :shared_secret)
24
+
25
+ attr_reader :alg_name
26
+
27
+ def initialize(alg_name)
28
+ @alg_name = alg_name
29
+ liboqs_alg_name = Algorithms.resolve_kem_name(alg_name)
30
+ @kem_ptr = Ffi::Kem.OQS_KEM_new(liboqs_alg_name)
31
+ if @kem_ptr.null?
32
+ raise PqcRails::Error,
33
+ "OQS_KEM_new failed for '#{liboqs_alg_name}'. " \
34
+ "liboqsがこのアルゴリズムを有効にしてビルドされているか確認してください。"
35
+ end
36
+
37
+ @kem = Ffi::Kem::Struct.new(@kem_ptr)
38
+ @freed = false
39
+ end
40
+
41
+ def length_public_key = @kem[:length_public_key]
42
+ def length_secret_key = @kem[:length_secret_key]
43
+ def length_ciphertext = @kem[:length_ciphertext]
44
+ def length_shared_secret = @kem[:length_shared_secret]
45
+
46
+ def generate_keypair
47
+ ensure_not_freed!
48
+
49
+ public_key = FFI::MemoryPointer.new(:uint8, length_public_key)
50
+ secret_key = FFI::MemoryPointer.new(:uint8, length_secret_key)
51
+
52
+ status = Ffi::Kem.OQS_KEM_keypair(@kem_ptr, public_key, secret_key)
53
+ raise PqcRails::Error, "OQS_KEM_keypair failed (status=#{status})" unless status.zero?
54
+
55
+ Keypair.new(
56
+ public_key.read_bytes(length_public_key),
57
+ secret_key.read_bytes(length_secret_key)
58
+ )
59
+ end
60
+
61
+ def encapsulate(public_key)
62
+ ensure_not_freed!
63
+ validate_length!(public_key, length_public_key, "public_key")
64
+
65
+ ciphertext = FFI::MemoryPointer.new(:uint8, length_ciphertext)
66
+ shared_secret = FFI::MemoryPointer.new(:uint8, length_shared_secret)
67
+ pk_ptr = FFI::MemoryPointer.new(:uint8, public_key.bytesize)
68
+ pk_ptr.put_bytes(0, public_key)
69
+
70
+ status = Ffi::Kem.OQS_KEM_encaps(@kem_ptr, ciphertext, shared_secret, pk_ptr)
71
+ raise PqcRails::Error, "OQS_KEM_encaps failed (status=#{status})" unless status.zero?
72
+
73
+ Encapsulation.new(
74
+ ciphertext.read_bytes(length_ciphertext),
75
+ shared_secret.read_bytes(length_shared_secret)
76
+ )
77
+ end
78
+
79
+ def decapsulate(ciphertext, secret_key)
80
+ ensure_not_freed!
81
+ validate_length!(ciphertext, length_ciphertext, "ciphertext")
82
+ validate_length!(secret_key, length_secret_key, "secret_key")
83
+
84
+ shared_secret = FFI::MemoryPointer.new(:uint8, length_shared_secret)
85
+ ct_ptr = FFI::MemoryPointer.new(:uint8, ciphertext.bytesize)
86
+ ct_ptr.put_bytes(0, ciphertext)
87
+ sk_ptr = FFI::MemoryPointer.new(:uint8, secret_key.bytesize)
88
+ sk_ptr.put_bytes(0, secret_key)
89
+
90
+ status = Ffi::Kem.OQS_KEM_decaps(@kem_ptr, shared_secret, ct_ptr, sk_ptr)
91
+ raise PqcRails::Error, "OQS_KEM_decaps failed (status=#{status})" unless status.zero?
92
+
93
+ shared_secret.read_bytes(length_shared_secret)
94
+ end
95
+
96
+ def free
97
+ return if @freed
98
+
99
+ Ffi::Kem.OQS_KEM_free(@kem_ptr)
100
+ @freed = true
101
+ end
102
+
103
+ def self.open(alg_name)
104
+ kem = new(alg_name)
105
+ yield kem
106
+ ensure
107
+ kem&.free
108
+ end
109
+
110
+ private
111
+
112
+ def ensure_not_freed!
113
+ raise PqcRails::Error, "this PqcRails::Kem instance has already been freed" if @freed
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "base64"
5
+ require_relative "blob_packing"
6
+ require_relative "hybrid_kem"
7
+
8
+ module PqcRails
9
+ class MissingKeyError < PqcRails::Error; end
10
+
11
+ # 環境変数→Rails credentialsの順で鍵を読み込み、HybridKem::Keypairへデコードするまでの
12
+ # 共通処理。Session::KeyManagerとActiveRecord::KeyProviderで同じ手順(fetch→無ければ例外→decode)
13
+ # が必要になるため共通化している。環境変数を優先するのは本番運用でcredentialsファイルを
14
+ # 使わずに鍵を渡せるようにするため(コンテナのsecret注入等と相性が良い)。
15
+ module KeySource
16
+ module_function
17
+
18
+ def fetch(env_var:, credentials_key:)
19
+ ENV.fetch(env_var, nil) || credentials_value(credentials_key)
20
+ end
21
+
22
+ # fetchした値がnilならMissingKeyErrorを送出する版。
23
+ def fetch!(env_var:, credentials_key:, label:)
24
+ fetch(env_var: env_var, credentials_key: credentials_key) ||
25
+ raise(MissingKeyError,
26
+ "PQC #{label} key not found. Set ENV['#{env_var}'] or run `rails generate pqc_rails:install`.")
27
+ end
28
+
29
+ # fetch! + decodeまでを1回で行う。Session::KeyManager#keypairとActiveRecord::KeyProvider#keypairの
30
+ # 両方が同じ「fetch→無ければ例外→decode」という処理列を重複実装していたため、ここに統合した。
31
+ def fetch_keypair!(env_var:, credentials_key:, label:)
32
+ decode(fetch!(env_var: env_var, credentials_key: credentials_key, label: label))
33
+ end
34
+
35
+ def credentials_value(credentials_key)
36
+ return nil unless Rails.respond_to?(:application) && Rails.application
37
+
38
+ Rails.application.credentials[credentials_key]
39
+ end
40
+
41
+ # HybridKem::Keypairのシリアライズ形式。セッション固有の処理ではなく鍵の保存形式そのものなので、
42
+ # Session::KeyManagerではなくここに置く(セッション/AR両方から使われる)。
43
+ def encode(keypair)
44
+ Base64.strict_encode64(BlobPacking.pack(keypair.public_key, keypair.secret_key))
45
+ end
46
+
47
+ def decode(encoded)
48
+ public_key, secret_key = BlobPacking.unpack(Base64.strict_decode64(encoded))
49
+ HybridKem::Keypair.new(public_key, secret_key)
50
+ end
51
+
52
+ # ENV/Rails credentialsから現行鍵・旧鍵世代を読み込むデフォルトの鍵ソース。
53
+ #
54
+ # Session::KeyManagerとActiveRecord::KeyProviderは、鍵の取得元をこのクラスのインスタンスに
55
+ # 委譲する。`#current_keypair`/`#previous_keypairs`という2メソッドさえ実装すれば、
56
+ # 将来HSM/PKCS#11経由の鍵ソースにも差し替えられる(実装はしないが、この分離だけで済むように
57
+ # しておく、というPhase4での抽象化)。
58
+ #
59
+ # 旧鍵世代は、ENVではカンマ区切りの文字列、credentialsでは配列として持たせる(ENVは
60
+ # 文字列しか持てないため)。世代数に上限は設けない。ローテーション完了後は運用側が
61
+ # previous_env_var/previous_credentials_keyを空にすることで旧鍵を無効化する。
62
+ class EnvCredentials
63
+ def initialize(env_var:, previous_env_var:, credentials_key:, previous_credentials_key:, label:)
64
+ @env_var = env_var
65
+ @previous_env_var = previous_env_var
66
+ @credentials_key = credentials_key
67
+ @previous_credentials_key = previous_credentials_key
68
+ @label = label
69
+ end
70
+
71
+ def current_keypair
72
+ KeySource.fetch_keypair!(env_var: @env_var, credentials_key: @credentials_key, label: @label)
73
+ end
74
+
75
+ def previous_keypairs
76
+ raw = KeySource.fetch(env_var: @previous_env_var, credentials_key: @previous_credentials_key)
77
+ return [] if raw.nil?
78
+
79
+ entries = raw.is_a?(::Array) ? raw : raw.split(",")
80
+ entries.map { |encoded| KeySource.decode(encoded.strip) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PqcRails
4
+ # 鍵・暗号文・署名等のバイト列長を検証する共通処理。
5
+ # Kem/Sig/DhKem/EnvelopeCipherで同じチェックが必要になるため切り出している。
6
+ module LengthValidation
7
+ private
8
+
9
+ def validate_length!(bytes, expected_length, name)
10
+ return if bytes.bytesize == expected_length
11
+
12
+ raise ArgumentError,
13
+ "#{name} has wrong length: expected #{expected_length} bytes, got #{bytes.bytesize}"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require_relative "../blob_packing"
5
+ require_relative "../hybrid_kem"
6
+ require_relative "../envelope_cipher"
7
+
8
+ module PqcRails
9
+ module Session
10
+ # HybridKem(KEM) + EnvelopeCipher(DEM)によるKEM-DEM構成のハイブリッド公開鍵暗号。
11
+ #
12
+ # セッションストアは同一サーバがencrypt(Cookie書き込み時)とdecrypt(Cookie読み込み時)の
13
+ # 両方を行うため、二者間のインタラクティブな鍵交換ではなく、毎回encapsulateし直す
14
+ # ランダム化された公開鍵暗号として使う。これにより、長期鍵(secret_key)はサーバにしか無くても、
15
+ # Cookieごとに異なるciphertextが作られる(per-cookieのforward secrecy的な性質を持つ)。
16
+ class Encryptor
17
+ DEFAULT_PQ_ALG_NAME = :ml_kem_768
18
+
19
+ def initialize(keypair, pq_alg_name: DEFAULT_PQ_ALG_NAME)
20
+ @keypair = keypair
21
+ @pq_alg_name = pq_alg_name
22
+ end
23
+
24
+ def encrypt(data)
25
+ serialized = Marshal.dump(data)
26
+
27
+ HybridKem.open(@pq_alg_name) do |hybrid|
28
+ encap = hybrid.encapsulate(@keypair.public_key)
29
+ encrypted = EnvelopeCipher.new(encap.shared_secret).encrypt(serialized)
30
+
31
+ Base64.strict_encode64(BlobPacking.pack(encap.ciphertext, encrypted))
32
+ end
33
+ end
34
+
35
+ def decrypt(blob)
36
+ ciphertext, encrypted = BlobPacking.unpack(Base64.strict_decode64(blob))
37
+
38
+ HybridKem.open(@pq_alg_name) do |hybrid|
39
+ shared_secret = hybrid.decapsulate(ciphertext, @keypair.secret_key)
40
+ Marshal.load(EnvelopeCipher.new(shared_secret).decrypt(encrypted))
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../key_source"
4
+
5
+ module PqcRails
6
+ module Session
7
+ # セッション暗号化用のHybridKemキーペアを、環境変数またはRails credentialsから読み込む。
8
+ # 実際のfetch/decode処理はKeySource::EnvCredentialsに一本化されており、ここはセッション用の
9
+ # ENV変数名/credentialsキーを添えて委譲するだけ。
10
+ #
11
+ # 鍵ローテーション時は#previous_keypairsが旧鍵世代を返す。PqcCookieStoreはこれを使って
12
+ # 現行鍵で復号できなかったCookieを旧鍵で試す。セッションはDBと違い自然に失効するため、
13
+ # ロールバック期間(概ね最長セッション有効期限)を過ぎたら運用側がPREVIOUS_ENV_VARを
14
+ # 空にすれば旧鍵は自動的に無効化される。
15
+ module KeyManager
16
+ ENV_VAR = "PQC_SESSION_KEY"
17
+ PREVIOUS_ENV_VAR = "PQC_SESSION_PREVIOUS_KEYS"
18
+ CREDENTIALS_KEY = :pqc_session_key
19
+ PREVIOUS_CREDENTIALS_KEY = :pqc_session_previous_keys
20
+
21
+ module_function
22
+
23
+ def keypair
24
+ key_source.current_keypair
25
+ end
26
+
27
+ def previous_keypairs
28
+ key_source.previous_keypairs
29
+ end
30
+
31
+ def encode(keypair) = KeySource.encode(keypair)
32
+ def decode(encoded) = KeySource.decode(encoded)
33
+
34
+ def key_source
35
+ KeySource::EnvCredentials.new(
36
+ env_var: ENV_VAR, previous_env_var: PREVIOUS_ENV_VAR,
37
+ credentials_key: CREDENTIALS_KEY, previous_credentials_key: PREVIOUS_CREDENTIALS_KEY,
38
+ label: "session"
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+ require_relative "encryptor"
5
+ require_relative "key_manager"
6
+
7
+ module PqcRails
8
+ module Session
9
+ # ActionDispatch::Session::CookieStoreを継承し、Railsのsecret_key_baseによる
10
+ # AES暗号化(signed_or_encryptedクッキージャー)を使わず、HybridKem(PQC)で
11
+ # セッション内容そのものを暗号化する。
12
+ #
13
+ # set_cookie/get_cookieはCookieStoreがRailsの暗号化クッキージャーへの委譲点として
14
+ # 用意しているprivateな拡張点(Rails 7.1〜8.1で同一シグネチャ)。ここを上書きして
15
+ # 生のcookie_jar(署名・暗号化なし)に対してEncryptorで暗号化した文字列を直接書き込む。
16
+ # AEAD(AES-256-GCM)が改竄検知も兼ねるため、Railsの署名は不要。
17
+ #
18
+ # 鍵ローテーション対応: 書き込みは常に現行鍵(@encryptor)のみを使う。読み込みは
19
+ # 現行鍵で失敗した場合、previous_keypairs(鍵ローテーション前の旧鍵)で順に再試行する。
20
+ # previous_keypairsはDB側のようにprevious:機構で永続する必要はなく、ローテーション後
21
+ # 発行済みの旧セッションCookieが自然に失効するまでの一時的な設定という位置づけ。
22
+ class PqcCookieStore < ActionDispatch::Session::CookieStore
23
+ def initialize(app, options = {})
24
+ keypair = options.delete(:keypair) || KeyManager.keypair
25
+ previous_keypairs = options.delete(:previous_keypairs) || KeyManager.previous_keypairs
26
+ pq_alg_name = options.delete(:pq_alg_name) || Encryptor::DEFAULT_PQ_ALG_NAME
27
+ @encryptor = Encryptor.new(keypair, pq_alg_name: pq_alg_name)
28
+ @previous_encryptors = previous_keypairs.map { |kp| Encryptor.new(kp, pq_alg_name: pq_alg_name) }
29
+ super(app, options)
30
+ end
31
+
32
+ private
33
+
34
+ def set_cookie(request, _session_id, cookie)
35
+ cookie[:value] = @encryptor.encrypt(cookie[:value])
36
+ request.cookie_jar[@key] = cookie
37
+ end
38
+
39
+ # 現行鍵→旧鍵世代の順に復号を試す(鍵ローテーション対応)。全て失敗、または
40
+ # そもそもCookieが無い場合はnilを返し、改竄・不正なCookieと同様に空セッションとして扱う。
41
+ # Railsの署名/暗号化クッキージャーが復号失敗時にnilを返す挙動に揃えている。
42
+ def get_cookie(request)
43
+ raw = request.cookie_jar[@key]
44
+ return nil if raw.nil?
45
+
46
+ [@encryptor, *@previous_encryptors].each do |encryptor|
47
+ return encryptor.decrypt(raw)
48
+ rescue StandardError
49
+ next
50
+ end
51
+
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end