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.
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 = String(bytes).b
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
- PQCrypto.__send__(:native_ct_equals, other.send(:bytes_for_native), @bytes)
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 = String(bytes).b
230
- @seed = seed.nil? ? nil : String(seed).b
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 = String(seed).b
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
- case format
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
- case format
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), String(ciphertext).b, @bytes)
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
- PQCrypto.__send__(:native_ct_equals, other.send(:bytes_for_native), @bytes)
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::PRIVATE_KEY_CHOICES.fetch(@algorithm).fetch(:seed_bytes)
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 = String(ciphertext).b
340
- @shared_secret = String(shared_secret).b
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