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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE.txt +104 -0
- data/README.md +306 -0
- data/Rakefile +4 -0
- data/docs/CRYPTO_INVENTORY.md +47 -0
- data/docs/MIGRATION.md +107 -0
- data/docs/THREAT_MODEL.md +146 -0
- data/lib/pqc_rails/active_record/context.rb +42 -0
- data/lib/pqc_rails/active_record/key_provider.rb +67 -0
- data/lib/pqc_rails/algorithms.rb +74 -0
- data/lib/pqc_rails/blob_packing.rb +25 -0
- data/lib/pqc_rails/cipher.rb +74 -0
- data/lib/pqc_rails/configuration.rb +47 -0
- data/lib/pqc_rails/dh_kem.rb +66 -0
- data/lib/pqc_rails/envelope_cipher.rb +59 -0
- data/lib/pqc_rails/ffi/kem.rb +40 -0
- data/lib/pqc_rails/ffi/sig.rb +68 -0
- data/lib/pqc_rails/generators/install/install_generator.rb +48 -0
- data/lib/pqc_rails/generators/install/templates/initializer.rb +10 -0
- data/lib/pqc_rails/hybrid_kem.rb +128 -0
- data/lib/pqc_rails/kem.rb +116 -0
- data/lib/pqc_rails/key_source.rb +84 -0
- data/lib/pqc_rails/length_validation.rb +16 -0
- data/lib/pqc_rails/session/encryptor.rb +45 -0
- data/lib/pqc_rails/session/key_manager.rb +43 -0
- data/lib/pqc_rails/session/pqc_cookie_store.rb +56 -0
- data/lib/pqc_rails/sig.rb +159 -0
- data/lib/pqc_rails/version.rb +5 -0
- data/lib/pqc_rails.rb +26 -0
- data/sig/pqc_rails.rbs +4 -0
- metadata +151 -0
|
@@ -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
|