pq_crypto 0.6.0 → 0.6.1
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 +4 -4
- data/CHANGELOG.md +7 -0
- data/ext/pqcrypto/extconf.rb +2 -0
- data/ext/pqcrypto/pqcrypto_ruby_secure.c +139 -0
- data/ext/pqcrypto/pqcrypto_secure.c +532 -0
- data/ext/pqcrypto/pqcrypto_secure.h +20 -0
- data/ext/pqcrypto/pqcrypto_version.h +1 -1
- data/lib/pq_crypto/hybrid_kem.rb +1 -1
- data/lib/pq_crypto/internal.rb +23 -0
- data/lib/pq_crypto/kem.rb +27 -34
- data/lib/pq_crypto/pkcs8/der.rb +68 -0
- data/lib/pq_crypto/pkcs8/private_key_choice.rb +186 -0
- data/lib/pq_crypto/pkcs8.rb +51 -468
- data/lib/pq_crypto/serialization.rb +19 -29
- data/lib/pq_crypto/signature.rb +28 -35
- data/lib/pq_crypto/version.rb +1 -1
- data/lib/pq_crypto.rb +10 -0
- metadata +4 -1
data/lib/pq_crypto/kem.rb
CHANGED
|
@@ -158,7 +158,7 @@ module PQCrypto
|
|
|
158
158
|
|
|
159
159
|
def initialize(algorithm, bytes)
|
|
160
160
|
@algorithm = algorithm
|
|
161
|
-
@bytes =
|
|
161
|
+
@bytes = Internal.binary_string(bytes)
|
|
162
162
|
validate_length!
|
|
163
163
|
end
|
|
164
164
|
|
|
@@ -196,7 +196,7 @@ module PQCrypto
|
|
|
196
196
|
|
|
197
197
|
def ==(other)
|
|
198
198
|
return false unless other.is_a?(PublicKey) && other.algorithm == algorithm
|
|
199
|
-
|
|
199
|
+
Internal.constant_time_equal?(other.send(:bytes_for_native), @bytes)
|
|
200
200
|
end
|
|
201
201
|
|
|
202
202
|
alias eql? ==
|
|
@@ -226,14 +226,14 @@ module PQCrypto
|
|
|
226
226
|
|
|
227
227
|
def initialize(algorithm, bytes, seed: nil)
|
|
228
228
|
@algorithm = algorithm
|
|
229
|
-
@bytes =
|
|
230
|
-
@seed = seed.nil? ? nil :
|
|
229
|
+
@bytes = Internal.binary_string(bytes)
|
|
230
|
+
@seed = seed.nil? ? nil : Internal.binary_string(seed)
|
|
231
231
|
validate_length!
|
|
232
232
|
validate_seed_length! if @seed
|
|
233
233
|
end
|
|
234
234
|
|
|
235
235
|
def self.from_seed(algorithm, seed)
|
|
236
|
-
seed_bytes =
|
|
236
|
+
seed_bytes = Internal.binary_string(seed)
|
|
237
237
|
_public_key, expanded = PQCrypto.__send__(KEM.send(:native_method_for, algorithm, :keypair_from_seed), seed_bytes)
|
|
238
238
|
new(algorithm, expanded, seed: seed_bytes)
|
|
239
239
|
rescue ArgumentError => e
|
|
@@ -253,37 +253,15 @@ module PQCrypto
|
|
|
253
253
|
end
|
|
254
254
|
|
|
255
255
|
def to_pkcs8_der(format: :expanded, passphrase: nil, iterations: PKCS8::ENCRYPTED_PKCS8_DEFAULT_ITERATIONS)
|
|
256
|
-
|
|
257
|
-
when :expanded
|
|
258
|
-
PKCS8.encode_der(@algorithm, @bytes, format: :expanded, passphrase: passphrase, iterations: iterations)
|
|
259
|
-
when :seed
|
|
260
|
-
ensure_seed_available!(format)
|
|
261
|
-
PKCS8.encode_der(@algorithm, @seed, format: :seed, passphrase: passphrase, iterations: iterations)
|
|
262
|
-
when :both
|
|
263
|
-
ensure_seed_available!(format)
|
|
264
|
-
PKCS8.encode_der(@algorithm, [@seed, @bytes], format: :both, passphrase: passphrase, iterations: iterations)
|
|
265
|
-
else
|
|
266
|
-
raise SerializationError, "Unsupported PKCS#8 private key format: #{format.inspect}"
|
|
267
|
-
end
|
|
256
|
+
PKCS8.encode_der(@algorithm, pkcs8_material(format), format: format, passphrase: passphrase, iterations: iterations)
|
|
268
257
|
end
|
|
269
258
|
|
|
270
259
|
def to_pkcs8_pem(format: :expanded, passphrase: nil, iterations: PKCS8::ENCRYPTED_PKCS8_DEFAULT_ITERATIONS)
|
|
271
|
-
|
|
272
|
-
when :expanded
|
|
273
|
-
PKCS8.encode_pem(@algorithm, @bytes, format: :expanded, passphrase: passphrase, iterations: iterations)
|
|
274
|
-
when :seed
|
|
275
|
-
ensure_seed_available!(format)
|
|
276
|
-
PKCS8.encode_pem(@algorithm, @seed, format: :seed, passphrase: passphrase, iterations: iterations)
|
|
277
|
-
when :both
|
|
278
|
-
ensure_seed_available!(format)
|
|
279
|
-
PKCS8.encode_pem(@algorithm, [@seed, @bytes], format: :both, passphrase: passphrase, iterations: iterations)
|
|
280
|
-
else
|
|
281
|
-
raise SerializationError, "Unsupported PKCS#8 private key format: #{format.inspect}"
|
|
282
|
-
end
|
|
260
|
+
PKCS8.encode_pem(@algorithm, pkcs8_material(format), format: format, passphrase: passphrase, iterations: iterations)
|
|
283
261
|
end
|
|
284
262
|
|
|
285
263
|
def decapsulate(ciphertext)
|
|
286
|
-
PQCrypto.__send__(KEM.send(:native_method_for, @algorithm, :decapsulate),
|
|
264
|
+
PQCrypto.__send__(KEM.send(:native_method_for, @algorithm, :decapsulate), Internal.binary_string(ciphertext), @bytes)
|
|
287
265
|
rescue ArgumentError => e
|
|
288
266
|
raise InvalidCiphertextError, e.message
|
|
289
267
|
end
|
|
@@ -296,7 +274,7 @@ module PQCrypto
|
|
|
296
274
|
|
|
297
275
|
def ==(other)
|
|
298
276
|
return false unless other.is_a?(SecretKey) && other.algorithm == algorithm
|
|
299
|
-
|
|
277
|
+
Internal.constant_time_equal?(other.send(:bytes_for_native), @bytes)
|
|
300
278
|
end
|
|
301
279
|
|
|
302
280
|
alias eql? ==
|
|
@@ -320,8 +298,23 @@ module PQCrypto
|
|
|
320
298
|
raise InvalidKeyError, "Invalid KEM secret key length" unless @bytes.bytesize == expected
|
|
321
299
|
end
|
|
322
300
|
|
|
301
|
+
def pkcs8_material(format)
|
|
302
|
+
case format
|
|
303
|
+
when :expanded
|
|
304
|
+
@bytes
|
|
305
|
+
when :seed
|
|
306
|
+
ensure_seed_available!(format)
|
|
307
|
+
@seed
|
|
308
|
+
when :both
|
|
309
|
+
ensure_seed_available!(format)
|
|
310
|
+
[@seed, @bytes]
|
|
311
|
+
else
|
|
312
|
+
raise SerializationError, "Unsupported PKCS#8 private key format: #{format.inspect}"
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
323
316
|
def validate_seed_length!
|
|
324
|
-
expected = PKCS8::
|
|
317
|
+
expected = PKCS8::PrivateKeyChoice.seed_bytes(@algorithm)
|
|
325
318
|
raise InvalidKeyError, "Invalid KEM seed length" unless @seed.bytesize == expected
|
|
326
319
|
end
|
|
327
320
|
|
|
@@ -336,8 +329,8 @@ module PQCrypto
|
|
|
336
329
|
attr_reader :ciphertext, :shared_secret
|
|
337
330
|
|
|
338
331
|
def initialize(ciphertext, shared_secret)
|
|
339
|
-
@ciphertext =
|
|
340
|
-
@shared_secret =
|
|
332
|
+
@ciphertext = Internal.binary_string(ciphertext)
|
|
333
|
+
@shared_secret = Internal.binary_string(shared_secret)
|
|
341
334
|
end
|
|
342
335
|
|
|
343
336
|
def inspect
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PQCrypto
|
|
4
|
+
module PKCS8
|
|
5
|
+
module DER
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def encode_tlv(tag, value)
|
|
9
|
+
tag.chr.b + encode_length(value.bytesize) + value
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def decode_tlv(der, offset, expected_tag:, label:)
|
|
13
|
+
tag = der.getbyte(offset)
|
|
14
|
+
raise SerializationError, "PKCS#8 #{label} is missing" if tag.nil?
|
|
15
|
+
unless tag == expected_tag
|
|
16
|
+
raise SerializationError, "PKCS#8 #{label} has unexpected tag: 0x#{tag.to_s(16).rjust(2, '0')}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
length, length_bytes = decode_length(der, offset + 1)
|
|
20
|
+
value_offset = offset + 1 + length_bytes
|
|
21
|
+
value_end = value_offset + length
|
|
22
|
+
raise SerializationError, "PKCS#8 #{label} length exceeds available data" if value_end > der.bytesize
|
|
23
|
+
|
|
24
|
+
[tag, der.byteslice(value_offset, length).b, value_end]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def encode_length(length)
|
|
28
|
+
raise SerializationError, "Invalid DER length" if length.negative?
|
|
29
|
+
return length.chr.b if length < 0x80
|
|
30
|
+
|
|
31
|
+
encoded = []
|
|
32
|
+
remaining = length
|
|
33
|
+
until remaining.zero?
|
|
34
|
+
encoded.unshift(remaining & 0xff)
|
|
35
|
+
remaining >>= 8
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
(0x80 | encoded.length).chr.b + encoded.pack("C*").b
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def decode_length(der, offset)
|
|
42
|
+
first = der.getbyte(offset)
|
|
43
|
+
raise SerializationError, "PKCS#8 DER length is missing" if first.nil?
|
|
44
|
+
|
|
45
|
+
return [first, 1] if first < 0x80
|
|
46
|
+
|
|
47
|
+
length_octets = first & 0x7f
|
|
48
|
+
raise SerializationError, "PKCS#8 DER indefinite length is not allowed" if length_octets.zero?
|
|
49
|
+
raise SerializationError, "PKCS#8 DER length is too large" if length_octets > 4
|
|
50
|
+
if offset + 1 + length_octets > der.bytesize
|
|
51
|
+
raise SerializationError, "PKCS#8 DER length exceeds available data"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
length = 0
|
|
55
|
+
length_octets.times do |i|
|
|
56
|
+
byte = der.getbyte(offset + 1 + i)
|
|
57
|
+
length = (length << 8) | byte
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if length < 0x80 || (length_octets > 1 && der.getbyte(offset + 1).zero?)
|
|
61
|
+
raise SerializationError, "PKCS#8 DER length is not minimally encoded"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
[length, 1 + length_octets]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PQCrypto
|
|
4
|
+
module PKCS8
|
|
5
|
+
module PrivateKeyChoice
|
|
6
|
+
SEED_TAG = 0x80
|
|
7
|
+
EXPANDED_TAG = 0x04
|
|
8
|
+
BOTH_TAG = 0x30
|
|
9
|
+
|
|
10
|
+
KEYPAIR_FROM_SEED_METHODS = {
|
|
11
|
+
ml_kem_512: :native_ml_kem_512_keypair_from_seed,
|
|
12
|
+
ml_kem_768: :native_ml_kem_keypair_from_seed,
|
|
13
|
+
ml_kem_1024: :native_ml_kem_1024_keypair_from_seed,
|
|
14
|
+
ml_dsa_44: :native_ml_dsa_44_keypair_from_seed,
|
|
15
|
+
ml_dsa_65: :native_ml_dsa_keypair_from_seed,
|
|
16
|
+
ml_dsa_87: :native_ml_dsa_87_keypair_from_seed,
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def encode(algorithm, secret_material, format)
|
|
22
|
+
ensure_format_supported!(algorithm, format)
|
|
23
|
+
|
|
24
|
+
case format
|
|
25
|
+
when :seed
|
|
26
|
+
encode_seed(algorithm, secret_material)
|
|
27
|
+
when :expanded
|
|
28
|
+
encode_expanded(algorithm, secret_material)
|
|
29
|
+
when :both
|
|
30
|
+
encode_both(algorithm, secret_material)
|
|
31
|
+
else
|
|
32
|
+
raise SerializationError, "Unsupported PKCS#8 private key format: #{format.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def decode(algorithm, choice_der)
|
|
37
|
+
tag = choice_der.getbyte(0)
|
|
38
|
+
raise SerializationError, "PKCS#8 privateKey CHOICE is empty" if tag.nil?
|
|
39
|
+
|
|
40
|
+
case tag
|
|
41
|
+
when SEED_TAG
|
|
42
|
+
ensure_format_supported!(algorithm, :seed)
|
|
43
|
+
decode_seed(algorithm, choice_der)
|
|
44
|
+
when EXPANDED_TAG
|
|
45
|
+
ensure_format_supported!(algorithm, :expanded)
|
|
46
|
+
decode_expanded(algorithm, choice_der)
|
|
47
|
+
when BOTH_TAG
|
|
48
|
+
ensure_format_supported!(algorithm, :both)
|
|
49
|
+
decode_both(algorithm, choice_der)
|
|
50
|
+
else
|
|
51
|
+
raise SerializationError,
|
|
52
|
+
"Unsupported PKCS#8 #{algorithm.inspect} private key CHOICE tag: 0x#{tag.to_s(16).rjust(2, '0')}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_secret_key_algorithm!(algorithm, entry)
|
|
57
|
+
return if PKCS8::PRIVATE_KEY_CHOICES.key?(algorithm) && %i[ml_kem ml_dsa].include?(entry.fetch(:family))
|
|
58
|
+
|
|
59
|
+
raise SerializationError, "PKCS#8 private key codec is not supported for #{algorithm.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ensure_format_supported!(algorithm, format)
|
|
63
|
+
if ml_dsa_algorithm?(algorithm) && %i[seed both].include?(format) && !PKCS8.allow_ml_dsa_seed_format
|
|
64
|
+
raise SerializationError,
|
|
65
|
+
"ML-DSA seed-format PKCS#8 is opt-in; set PQCrypto::PKCS8.allow_ml_dsa_seed_format = true to enable (see SECURITY.md for caveats)"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return if profile(algorithm).fetch(:supported_formats).include?(format)
|
|
69
|
+
|
|
70
|
+
raise SerializationError, "Unsupported PKCS#8 private key format for #{algorithm.inspect}: #{format.inspect}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def seed_bytes(algorithm)
|
|
74
|
+
profile(algorithm).fetch(:seed_bytes)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def expanded_bytes(algorithm)
|
|
78
|
+
profile(algorithm).fetch(:expanded_bytes)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ml_dsa_algorithm?(algorithm)
|
|
82
|
+
%i[ml_dsa_44 ml_dsa_65 ml_dsa_87].include?(algorithm)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def profile(algorithm)
|
|
86
|
+
PKCS8::PRIVATE_KEY_CHOICES.fetch(algorithm) do
|
|
87
|
+
raise SerializationError, "PKCS#8 private key codec is not supported for #{algorithm.inspect}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_seed_length!(algorithm, seed)
|
|
92
|
+
expected = seed_bytes(algorithm)
|
|
93
|
+
return if seed.bytesize == expected
|
|
94
|
+
|
|
95
|
+
raise SerializationError,
|
|
96
|
+
"Invalid #{algorithm.inspect} seed private key length: expected #{expected}, got #{seed.bytesize}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_expanded_length!(algorithm, expanded)
|
|
100
|
+
expected = expanded_bytes(algorithm)
|
|
101
|
+
return if expanded.bytesize == expected
|
|
102
|
+
|
|
103
|
+
raise SerializationError,
|
|
104
|
+
"Invalid #{algorithm.inspect} expanded private key length: expected #{expected}, got #{expanded.bytesize}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def decode_seed(algorithm, choice_der)
|
|
108
|
+
_tag, seed, next_offset = DER.decode_tlv(choice_der, 0, expected_tag: SEED_TAG, label: "seed")
|
|
109
|
+
raise SerializationError, "PKCS#8 seed contains trailing data" unless next_offset == choice_der.bytesize
|
|
110
|
+
|
|
111
|
+
validate_seed_length!(algorithm, seed)
|
|
112
|
+
[algorithm, :seed, seed]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def decode_expanded(algorithm, choice_der)
|
|
116
|
+
_tag, bytes, next_offset = DER.decode_tlv(choice_der, 0, expected_tag: EXPANDED_TAG, label: "expandedKey")
|
|
117
|
+
raise SerializationError, "PKCS#8 expandedKey contains trailing data" unless next_offset == choice_der.bytesize
|
|
118
|
+
|
|
119
|
+
validate_expanded_length!(algorithm, bytes)
|
|
120
|
+
[algorithm, :expanded, bytes]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def decode_both(algorithm, choice_der)
|
|
124
|
+
_tag, body, next_offset = DER.decode_tlv(choice_der, 0, expected_tag: BOTH_TAG, label: "both")
|
|
125
|
+
raise SerializationError, "PKCS#8 both contains trailing data" unless next_offset == choice_der.bytesize
|
|
126
|
+
|
|
127
|
+
_seed_tag, seed_bytes, offset = DER.decode_tlv(body, 0, expected_tag: EXPANDED_TAG, label: "both seed")
|
|
128
|
+
_expanded_tag, expanded_bytes, offset = DER.decode_tlv(body, offset, expected_tag: EXPANDED_TAG, label: "both expandedKey")
|
|
129
|
+
raise SerializationError, "PKCS#8 both must contain exactly 2 elements" unless offset == body.bytesize
|
|
130
|
+
|
|
131
|
+
validate_seed_length!(algorithm, seed_bytes)
|
|
132
|
+
validate_expanded_length!(algorithm, expanded_bytes)
|
|
133
|
+
verify_both_consistency!(algorithm, seed_bytes, expanded_bytes)
|
|
134
|
+
|
|
135
|
+
[algorithm, :both, [seed_bytes, expanded_bytes]]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def encode_seed(algorithm, secret_material)
|
|
139
|
+
seed = Internal.binary_string(secret_material)
|
|
140
|
+
validate_seed_length!(algorithm, seed)
|
|
141
|
+
|
|
142
|
+
DER.encode_tlv(SEED_TAG, seed)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def encode_expanded(algorithm, secret_material)
|
|
146
|
+
bytes = Internal.binary_string(secret_material)
|
|
147
|
+
validate_expanded_length!(algorithm, bytes)
|
|
148
|
+
|
|
149
|
+
DER.encode_tlv(EXPANDED_TAG, bytes)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def encode_both(algorithm, secret_material)
|
|
153
|
+
unless secret_material.is_a?(Array) && secret_material.size == 2
|
|
154
|
+
raise SerializationError, "PKCS#8 both format requires [seed, expandedKey]"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
seed, expanded = secret_material
|
|
158
|
+
seed_bytes = Internal.binary_string(seed)
|
|
159
|
+
expanded_bytes = Internal.binary_string(expanded)
|
|
160
|
+
validate_seed_length!(algorithm, seed_bytes)
|
|
161
|
+
validate_expanded_length!(algorithm, expanded_bytes)
|
|
162
|
+
|
|
163
|
+
body = DER.encode_tlv(EXPANDED_TAG, seed_bytes) + DER.encode_tlv(EXPANDED_TAG, expanded_bytes)
|
|
164
|
+
DER.encode_tlv(BOTH_TAG, body)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def verify_both_consistency!(algorithm, seed, expanded)
|
|
168
|
+
native_method = KEYPAIR_FROM_SEED_METHODS.fetch(algorithm, nil)
|
|
169
|
+
return if native_method.nil?
|
|
170
|
+
|
|
171
|
+
_public_key, expected_expanded = PQCrypto.__send__(native_method, seed)
|
|
172
|
+
return if Internal.constant_time_equal?(expected_expanded, expanded)
|
|
173
|
+
|
|
174
|
+
raise SerializationError, consistency_error_message(algorithm)
|
|
175
|
+
ensure
|
|
176
|
+
Internal.safe_wipe(expected_expanded) if defined?(expected_expanded)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def consistency_error_message(algorithm)
|
|
180
|
+
return "seed/expandedKey inconsistency in ML-DSA PKCS#8 'both' encoding (RFC 9881 §6)" if ml_dsa_algorithm?(algorithm)
|
|
181
|
+
|
|
182
|
+
"seed/expandedKey inconsistency in PKCS#8 'both' encoding (RFC 9935 §8)"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|