pq_crypto 0.5.2 → 0.6.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.
@@ -7,8 +7,16 @@ module PQCrypto
7
7
  PEM_LABEL = "PRIVATE KEY"
8
8
  PEM_BEGIN = "-----BEGIN #{PEM_LABEL}-----"
9
9
  PEM_END = "-----END #{PEM_LABEL}-----"
10
+ ENCRYPTED_PEM_LABEL = "ENCRYPTED PRIVATE KEY"
11
+ ENCRYPTED_PEM_BEGIN = "-----BEGIN #{ENCRYPTED_PEM_LABEL}-----"
12
+ ENCRYPTED_PEM_END = "-----END #{ENCRYPTED_PEM_LABEL}-----"
10
13
  ML_KEM_SEED_BYTES = 64
11
14
  ML_DSA_SEED_BYTES = 32
15
+ PBES2_OID = "1.2.840.113549.1.5.13"
16
+ PBKDF2_OID = "1.2.840.113549.1.5.12"
17
+ HMAC_SHA256_OID = "1.2.840.113549.2.9"
18
+ AES_256_CBC_OID = "2.16.840.1.101.3.4.1.42"
19
+ ENCRYPTED_PKCS8_DEFAULT_ITERATIONS = 200_000
12
20
 
13
21
  @allow_ml_dsa_seed_format = false
14
22
 
@@ -48,7 +56,7 @@ module PQCrypto
48
56
  class << self
49
57
  attr_accessor :allow_ml_dsa_seed_format
50
58
 
51
- def encode_der(algorithm_symbol, secret_material, format:)
59
+ def encode_der(algorithm_symbol, secret_material, format:, passphrase: nil, iterations: ENCRYPTED_PKCS8_DEFAULT_ITERATIONS)
52
60
  entry = AlgorithmRegistry.fetch(algorithm_symbol)
53
61
  validate_secret_key_algorithm!(algorithm_symbol, entry)
54
62
  ensure_format_supported!(algorithm_symbol, format)
@@ -64,28 +72,51 @@ module PQCrypto
64
72
  raise SerializationError, "Unsupported PKCS#8 private key format: #{format.inspect}"
65
73
  end
66
74
 
67
- OpenSSL::ASN1::Sequence.new([
75
+ der = OpenSSL::ASN1::Sequence.new([
68
76
  OpenSSL::ASN1::Integer.new(0),
69
77
  OpenSSL::ASN1::Sequence.new([
70
78
  OpenSSL::ASN1::ObjectId.new(AlgorithmRegistry.standard_oid(algorithm_symbol)),
71
79
  ]),
72
80
  OpenSSL::ASN1::OctetString.new(choice_der),
73
81
  ]).to_der.b
82
+
83
+ return der if passphrase.nil?
84
+
85
+ encrypt_der(der, passphrase: passphrase, iterations: iterations)
74
86
  rescue OpenSSL::ASN1::ASN1Error => e
75
87
  raise SerializationError, e.message
88
+ ensure
89
+ safe_wipe(choice_der) if defined?(choice_der)
90
+ safe_wipe(der) if passphrase && defined?(der)
76
91
  end
77
92
 
78
- def encode_pem(algorithm_symbol, secret_material, format:)
79
- der = encode_der(algorithm_symbol, secret_material, format: format)
93
+ def encode_pem(algorithm_symbol, secret_material, format:, passphrase: nil, iterations: ENCRYPTED_PKCS8_DEFAULT_ITERATIONS)
94
+ der = encode_der(algorithm_symbol, secret_material, format: format, passphrase: passphrase, iterations: iterations)
80
95
  body = encode_base64(der).scan(/.{1,64}/).join("\n")
81
- "#{PEM_BEGIN}\n#{body}\n#{PEM_END}\n"
96
+ if passphrase.nil?
97
+ "#{PEM_BEGIN}\n#{body}\n#{PEM_END}\n"
98
+ else
99
+ "#{ENCRYPTED_PEM_BEGIN}\n#{body}\n#{ENCRYPTED_PEM_END}\n"
100
+ end
82
101
  end
83
102
 
84
- def decode_der(der)
103
+ def decode_der(der, passphrase: nil)
85
104
  input = String(der).b
86
105
  outer = decode_asn1(input)
87
106
  raise SerializationError, "PKCS#8 DER contains trailing data" unless outer.to_der.b == input
88
107
  raise SerializationError, "PKCS#8 must be an ASN.1 SEQUENCE" unless outer.is_a?(OpenSSL::ASN1::Sequence)
108
+
109
+ if encrypted_private_key_info?(outer)
110
+ raise SerializationError, "Encrypted PKCS#8 requires passphrase" if passphrase.nil?
111
+
112
+ plain_der = decrypt_der(outer, passphrase: passphrase)
113
+ begin
114
+ return decode_der(plain_der)
115
+ ensure
116
+ safe_wipe(plain_der)
117
+ end
118
+ end
119
+
89
120
  raise SerializationError, "PKCS#8 OneAsymmetricKey must contain exactly 3 elements" unless outer.value.size == 3
90
121
 
91
122
  version, algorithm_identifier, private_key = outer.value
@@ -101,9 +132,9 @@ module PQCrypto
101
132
  decode_private_key_choice(algorithm, String(private_key.value).b)
102
133
  end
103
134
 
104
- def decode_pem(pem)
135
+ def decode_pem(pem, passphrase: nil)
105
136
  der = der_from_pem(pem)
106
- decode_der(der)
137
+ decode_der(der, passphrase: passphrase)
107
138
  end
108
139
 
109
140
  private
@@ -114,6 +145,146 @@ module PQCrypto
114
145
  raise SerializationError, e.message
115
146
  end
116
147
 
148
+ def safe_wipe(value)
149
+ return unless value.is_a?(String) && !value.frozen?
150
+
151
+ PQCrypto.secure_wipe(value)
152
+ rescue ArgumentError
153
+ nil
154
+ end
155
+
156
+ def encrypt_der(der, passphrase:, iterations:)
157
+ passphrase = String(passphrase)
158
+ iterations = Integer(iterations)
159
+ raise SerializationError, "Encrypted PKCS#8 iterations must be positive" unless iterations.positive?
160
+
161
+ salt = OpenSSL::Random.random_bytes(16)
162
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
163
+ cipher.encrypt
164
+ iv = cipher.random_iv
165
+ key = OpenSSL::PKCS5.pbkdf2_hmac(passphrase, salt, iterations, 32, OpenSSL::Digest::SHA256.new)
166
+ cipher.key = key
167
+ encrypted = cipher.update(String(der).b) + cipher.final
168
+ PQCrypto.secure_wipe(key) if key && !key.frozen?
169
+
170
+ encrypted_private_key_info_der(salt, iterations, iv, encrypted)
171
+ rescue ArgumentError, OpenSSL::Cipher::CipherError => e
172
+ raise SerializationError, e.message
173
+ end
174
+
175
+ def decrypt_der(outer, passphrase:)
176
+ salt, iterations, iv, encrypted = decode_encrypted_private_key_info(outer)
177
+ key = OpenSSL::PKCS5.pbkdf2_hmac(String(passphrase), salt, iterations, 32, OpenSSL::Digest::SHA256.new)
178
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
179
+ cipher.decrypt
180
+ cipher.key = key
181
+ cipher.iv = iv
182
+ (cipher.update(encrypted) + cipher.final).b
183
+ rescue ArgumentError, OpenSSL::Cipher::CipherError => e
184
+ raise SerializationError, "Failed to decrypt PKCS#8 private key: #{e.message}"
185
+ ensure
186
+ PQCrypto.secure_wipe(key) if defined?(key) && key && !key.frozen?
187
+ end
188
+
189
+ def encrypted_private_key_info_der(salt, iterations, iv, encrypted)
190
+ OpenSSL::ASN1::Sequence.new([
191
+ OpenSSL::ASN1::Sequence.new([
192
+ OpenSSL::ASN1::ObjectId.new(PBES2_OID),
193
+ OpenSSL::ASN1::Sequence.new([
194
+ OpenSSL::ASN1::Sequence.new([
195
+ OpenSSL::ASN1::ObjectId.new(PBKDF2_OID),
196
+ OpenSSL::ASN1::Sequence.new([
197
+ OpenSSL::ASN1::OctetString.new(salt),
198
+ OpenSSL::ASN1::Integer.new(iterations),
199
+ OpenSSL::ASN1::Integer.new(32),
200
+ OpenSSL::ASN1::Sequence.new([
201
+ OpenSSL::ASN1::ObjectId.new(HMAC_SHA256_OID),
202
+ OpenSSL::ASN1::Null.new(nil),
203
+ ]),
204
+ ]),
205
+ ]),
206
+ OpenSSL::ASN1::Sequence.new([
207
+ OpenSSL::ASN1::ObjectId.new(AES_256_CBC_OID),
208
+ OpenSSL::ASN1::OctetString.new(iv),
209
+ ]),
210
+ ]),
211
+ ]),
212
+ OpenSSL::ASN1::OctetString.new(encrypted),
213
+ ]).to_der.b
214
+ rescue OpenSSL::ASN1::ASN1Error => e
215
+ raise SerializationError, e.message
216
+ end
217
+
218
+ def encrypted_private_key_info?(outer)
219
+ outer.value.size == 2 &&
220
+ outer.value.first.is_a?(OpenSSL::ASN1::Sequence) &&
221
+ outer.value.first.value.first.is_a?(OpenSSL::ASN1::ObjectId) &&
222
+ outer.value.first.value.first.oid == PBES2_OID
223
+ end
224
+
225
+ def decode_encrypted_private_key_info(outer)
226
+ raise SerializationError, "Encrypted PKCS#8 must contain exactly 2 elements" unless outer.value.size == 2
227
+
228
+ algorithm_identifier, encrypted_data = outer.value
229
+ raise SerializationError, "Encrypted PKCS#8 encryptedData must be an OCTET STRING" unless encrypted_data.is_a?(OpenSSL::ASN1::OctetString)
230
+ raise SerializationError, "Encrypted PKCS#8 algorithm must be an AlgorithmIdentifier SEQUENCE" unless algorithm_identifier.is_a?(OpenSSL::ASN1::Sequence)
231
+ raise SerializationError, "Encrypted PKCS#8 AlgorithmIdentifier must contain PBES2 parameters" unless algorithm_identifier.value.size == 2
232
+
233
+ pbes2_oid, pbes2_params = algorithm_identifier.value
234
+ raise SerializationError, "Encrypted PKCS#8 algorithm must be PBES2" unless pbes2_oid.is_a?(OpenSSL::ASN1::ObjectId) && pbes2_oid.oid == PBES2_OID
235
+ raise SerializationError, "PBES2 parameters must be a SEQUENCE" unless pbes2_params.is_a?(OpenSSL::ASN1::Sequence)
236
+ raise SerializationError, "PBES2 parameters must contain KDF and encryption scheme" unless pbes2_params.value.size == 2
237
+
238
+ salt, iterations = decode_pbkdf2_params(pbes2_params.value[0])
239
+ iv = decode_aes256_cbc_params(pbes2_params.value[1])
240
+ [salt, iterations, iv, String(encrypted_data.value).b]
241
+ end
242
+
243
+ def decode_pbkdf2_params(kdf_alg)
244
+ raise SerializationError, "PBES2 KDF must be an AlgorithmIdentifier SEQUENCE" unless kdf_alg.is_a?(OpenSSL::ASN1::Sequence)
245
+ raise SerializationError, "PBES2 KDF AlgorithmIdentifier must contain parameters" unless kdf_alg.value.size == 2
246
+
247
+ oid, params = kdf_alg.value
248
+ raise SerializationError, "PBES2 KDF must be PBKDF2" unless oid.is_a?(OpenSSL::ASN1::ObjectId) && oid.oid == PBKDF2_OID
249
+ raise SerializationError, "PBKDF2 params must be a SEQUENCE" unless params.is_a?(OpenSSL::ASN1::Sequence)
250
+ raise SerializationError, "PBKDF2 params must contain salt, iteration count, key length, and PRF" unless params.value.size == 4
251
+
252
+ salt_asn1, iterations_asn1, key_length_asn1, prf_alg = params.value
253
+ raise SerializationError, "PBKDF2 salt must be an OCTET STRING" unless salt_asn1.is_a?(OpenSSL::ASN1::OctetString)
254
+ raise SerializationError, "PBKDF2 iterations must be an INTEGER" unless iterations_asn1.is_a?(OpenSSL::ASN1::Integer)
255
+ raise SerializationError, "PBKDF2 key length must be an INTEGER" unless key_length_asn1.is_a?(OpenSSL::ASN1::Integer)
256
+
257
+ iterations = iterations_asn1.value.to_i
258
+ key_length = key_length_asn1.value.to_i
259
+ raise SerializationError, "PBKDF2 iterations must be positive" unless iterations.positive?
260
+ raise SerializationError, "PBKDF2 key length must be 32 bytes" unless key_length == 32
261
+ validate_hmac_sha256_prf!(prf_alg)
262
+
263
+ [String(salt_asn1.value).b, iterations]
264
+ end
265
+
266
+ def validate_hmac_sha256_prf!(prf_alg)
267
+ raise SerializationError, "PBKDF2 PRF must be an AlgorithmIdentifier SEQUENCE" unless prf_alg.is_a?(OpenSSL::ASN1::Sequence)
268
+ raise SerializationError, "PBKDF2 PRF AlgorithmIdentifier must contain OID and NULL" unless prf_alg.value.size == 2
269
+
270
+ oid, null = prf_alg.value
271
+ raise SerializationError, "PBKDF2 PRF must be HMAC-SHA256" unless oid.is_a?(OpenSSL::ASN1::ObjectId) && oid.oid == HMAC_SHA256_OID
272
+ raise SerializationError, "PBKDF2 PRF parameters must be NULL" unless null.is_a?(OpenSSL::ASN1::Null)
273
+ end
274
+
275
+ def decode_aes256_cbc_params(encryption_scheme)
276
+ raise SerializationError, "PBES2 encryption scheme must be an AlgorithmIdentifier SEQUENCE" unless encryption_scheme.is_a?(OpenSSL::ASN1::Sequence)
277
+ raise SerializationError, "PBES2 encryption scheme must contain OID and IV" unless encryption_scheme.value.size == 2
278
+
279
+ oid, iv = encryption_scheme.value
280
+ raise SerializationError, "PBES2 encryption scheme must be AES-256-CBC" unless oid.is_a?(OpenSSL::ASN1::ObjectId) && oid.oid == AES_256_CBC_OID
281
+ raise SerializationError, "AES-256-CBC IV must be an OCTET STRING" unless iv.is_a?(OpenSSL::ASN1::OctetString)
282
+
283
+ iv_bytes = String(iv.value).b
284
+ raise SerializationError, "AES-256-CBC IV must be 16 bytes" unless iv_bytes.bytesize == 16
285
+ iv_bytes
286
+ end
287
+
117
288
  def decode_version(value)
118
289
  raise SerializationError, "PKCS#8 version must be an INTEGER" unless value.is_a?(OpenSSL::ASN1::Integer)
119
290
 
@@ -252,6 +423,8 @@ module PQCrypto
252
423
  "seed/expandedKey inconsistency in PKCS#8 'both' encoding (RFC 9935 §8)"
253
424
  end
254
425
  raise SerializationError, message
426
+ ensure
427
+ safe_wipe(expected_expanded) if defined?(expected_expanded)
255
428
  end
256
429
 
257
430
  def validate_seed_length!(algorithm, seed)
@@ -374,8 +547,9 @@ module PQCrypto
374
547
 
375
548
  def der_from_pem(pem)
376
549
  text = String(pem)
377
- match = text.match(/\A#{Regexp.escape(PEM_BEGIN)}\r?\n(?<body>[A-Za-z0-9+\/=\r\n]+)\r?\n#{Regexp.escape(PEM_END)}[ \t\r\n]*\z/)
378
- raise SerializationError, "Invalid PKCS#8 PEM: expected #{PEM_LABEL.inspect} label" unless match
550
+ match = text.match(/\A#{Regexp.escape(PEM_BEGIN)}\r?\n(?<body>[A-Za-z0-9+\/=\r\n]+)\r?\n#{Regexp.escape(PEM_END)}[ \t\r\n]*\z/) ||
551
+ text.match(/\A#{Regexp.escape(ENCRYPTED_PEM_BEGIN)}\r?\n(?<body>[A-Za-z0-9+\/=\r\n]+)\r?\n#{Regexp.escape(ENCRYPTED_PEM_END)}[ \t\r\n]*\z/)
552
+ raise SerializationError, "Invalid PKCS#8 PEM: expected #{PEM_LABEL.inspect} or #{ENCRYPTED_PEM_LABEL.inspect} label" unless match
379
553
 
380
554
  body = match[:body]
381
555
  raise SerializationError, "Invalid PKCS#8 PEM: embedded NUL in body" if body.include?("\0")
@@ -46,6 +46,10 @@ module PQCrypto
46
46
  SecretKey.new(algorithm, bytes)
47
47
  end
48
48
 
49
+ def secret_key_from_seed(algorithm, seed)
50
+ SecretKey.from_seed(resolve_algorithm!(algorithm), seed)
51
+ end
52
+
49
53
  def public_key_from_pqc_container_der(der, algorithm = nil)
50
54
  resolved_algorithm, bytes = Serialization.public_key_from_pqc_container_der(algorithm, der)
51
55
  resolve_algorithm!(resolved_algorithm)
@@ -82,12 +86,12 @@ module PQCrypto
82
86
  PublicKey.new(resolve_algorithm!(resolved_algorithm), bytes)
83
87
  end
84
88
 
85
- def secret_key_from_pkcs8_der(der)
86
- secret_key_from_decoded_pkcs8(*PKCS8.decode_der(der))
89
+ def secret_key_from_pkcs8_der(der, passphrase: nil)
90
+ secret_key_from_decoded_pkcs8(*PKCS8.decode_der(der, passphrase: passphrase))
87
91
  end
88
92
 
89
- def secret_key_from_pkcs8_pem(pem)
90
- secret_key_from_decoded_pkcs8(*PKCS8.decode_pem(pem))
93
+ def secret_key_from_pkcs8_pem(pem, passphrase: nil)
94
+ secret_key_from_decoded_pkcs8(*PKCS8.decode_pem(pem, passphrase: passphrase))
91
95
  end
92
96
 
93
97
  def details(algorithm)
@@ -114,10 +118,10 @@ module PQCrypto
114
118
  SecretKey.new(algorithm, material)
115
119
  when :seed
116
120
  _public_key, expanded = PQCrypto.__send__(native_method_for(algorithm, :keypair_from_seed), material)
117
- SecretKey.new(algorithm, expanded)
121
+ SecretKey.new(algorithm, expanded, seed: material)
118
122
  when :both
119
123
  _seed, expanded = material
120
- SecretKey.new(algorithm, expanded)
124
+ SecretKey.new(algorithm, expanded, seed: _seed)
121
125
  else
122
126
  raise SerializationError, "Unsupported ML-DSA PKCS#8 private key format: #{format.inspect}"
123
127
  end
@@ -145,9 +149,10 @@ module PQCrypto
145
149
  context = validate_context!(context)
146
150
  validate_io!(io)
147
151
 
152
+ algorithm = secret_key.algorithm
148
153
  sk_bytes = secret_key.__send__(:bytes_for_native)
149
154
  begin
150
- tr = PQCrypto.__send__(:_native_mldsa_extract_tr, sk_bytes)
155
+ tr = PQCrypto.__send__(:_native_mldsa_extract_tr, algorithm, sk_bytes)
151
156
  rescue ArgumentError => e
152
157
  raise InvalidKeyError, e.message
153
158
  end
@@ -159,7 +164,7 @@ module PQCrypto
159
164
  _drain_io_into_builder(io, builder, chunk_size)
160
165
  mu = PQCrypto.__send__(:_native_mldsa_mu_builder_finalize, builder)
161
166
  builder_consumed = true
162
- PQCrypto.__send__(:_native_mldsa_sign_mu, mu, sk_bytes)
167
+ PQCrypto.__send__(:_native_mldsa_sign_mu, algorithm, mu, sk_bytes)
163
168
  ensure
164
169
  PQCrypto.__send__(:_native_mldsa_mu_builder_release, builder) unless builder_consumed
165
170
  PQCrypto.secure_wipe(tr) if tr && !tr.frozen?
@@ -173,9 +178,10 @@ module PQCrypto
173
178
  context = validate_context!(context)
174
179
  validate_io!(io)
175
180
 
181
+ algorithm = public_key.algorithm
176
182
  pk_bytes = public_key.__send__(:bytes_for_native)
177
183
  begin
178
- tr = PQCrypto.__send__(:_native_mldsa_compute_tr, pk_bytes)
184
+ tr = PQCrypto.__send__(:_native_mldsa_compute_tr, algorithm, pk_bytes)
179
185
  rescue ArgumentError => e
180
186
  raise InvalidKeyError, e.message
181
187
  end
@@ -188,7 +194,7 @@ module PQCrypto
188
194
  _drain_io_into_builder(io, builder, chunk_size)
189
195
  mu = PQCrypto.__send__(:_native_mldsa_mu_builder_finalize, builder)
190
196
  builder_consumed = true
191
- PQCrypto.__send__(:_native_mldsa_verify_mu, mu, sig_bytes, pk_bytes)
197
+ PQCrypto.__send__(:_native_mldsa_verify_mu, algorithm, mu, sig_bytes, pk_bytes)
192
198
  ensure
193
199
  PQCrypto.__send__(:_native_mldsa_mu_builder_release, builder) unless builder_consumed
194
200
 
@@ -232,10 +238,7 @@ module PQCrypto
232
238
  end
233
239
 
234
240
  def validate_streaming_algorithm!(algorithm)
235
- return if resolve_algorithm!(algorithm) == CANONICAL_ALGORITHM
236
-
237
- raise UnsupportedAlgorithmError,
238
- "Streaming sign_io/verify_io currently supports only #{CANONICAL_ALGORITHM.inspect}"
241
+ resolve_algorithm!(algorithm)
239
242
  end
240
243
  end
241
244
 
@@ -285,14 +288,17 @@ module PQCrypto
285
288
  SPKI.encode_pem(@algorithm, @bytes)
286
289
  end
287
290
 
288
- def verify(message, signature)
289
- PQCrypto.__send__(Signature.send(:native_method_for, @algorithm, :verify), String(message).b, String(signature).b, @bytes)
290
- rescue ArgumentError => e
291
- raise InvalidKeyError, e.message
291
+ def verify(message, signature, context: "".b)
292
+ context = Signature.send(:validate_context!, context)
293
+ begin
294
+ PQCrypto.__send__(Signature.send(:native_method_for, @algorithm, :verify), String(message).b, String(signature).b, @bytes, context)
295
+ rescue ArgumentError => e
296
+ raise InvalidKeyError, e.message
297
+ end
292
298
  end
293
299
 
294
- def verify!(message, signature)
295
- raise PQCrypto::VerificationError, "Verification failed" unless verify(message, signature)
300
+ def verify!(message, signature, context: "".b)
301
+ raise PQCrypto::VerificationError, "Verification failed" unless verify(message, signature, context: context)
296
302
  true
297
303
  end
298
304
 
@@ -309,7 +315,7 @@ module PQCrypto
309
315
 
310
316
  def ==(other)
311
317
  return false unless other.is_a?(PublicKey) && other.algorithm == algorithm
312
- PQCrypto.__send__(:native_ct_equals, other.to_bytes, @bytes)
318
+ PQCrypto.__send__(:native_ct_equals, other.send(:bytes_for_native), @bytes)
313
319
  end
314
320
 
315
321
  alias eql? ==
@@ -337,10 +343,20 @@ module PQCrypto
337
343
  class SecretKey
338
344
  attr_reader :algorithm
339
345
 
340
- def initialize(algorithm, bytes)
346
+ def initialize(algorithm, bytes, seed: nil)
341
347
  @algorithm = algorithm
342
348
  @bytes = String(bytes).b
349
+ @seed = seed.nil? ? nil : String(seed).b
343
350
  validate_length!
351
+ validate_seed_length! if @seed
352
+ end
353
+
354
+ def self.from_seed(algorithm, seed)
355
+ seed_bytes = String(seed).b
356
+ _public_key, expanded = PQCrypto.__send__(Signature.send(:native_method_for, algorithm, :keypair_from_seed), seed_bytes)
357
+ new(algorithm, expanded, seed: seed_bytes)
358
+ rescue ArgumentError => e
359
+ raise InvalidKeyError, e.message
344
360
  end
345
361
 
346
362
  def to_bytes
@@ -355,34 +371,43 @@ module PQCrypto
355
371
  Serialization.secret_key_to_pqc_container_pem(@algorithm, @bytes)
356
372
  end
357
373
 
358
- def to_pkcs8_der(format: :expanded)
374
+ def to_pkcs8_der(format: :expanded, passphrase: nil, iterations: PKCS8::ENCRYPTED_PKCS8_DEFAULT_ITERATIONS)
359
375
  case format
360
376
  when :expanded
361
- PKCS8.encode_der(@algorithm, @bytes, format: :expanded)
362
- when :seed, :both
363
- raise SerializationError,
364
- "ML-DSA seed/both PKCS#8 export requires original seed material; use PQCrypto::PKCS8.encode_der/encode_pem directly"
377
+ PKCS8.encode_der(@algorithm, @bytes, format: :expanded, passphrase: passphrase, iterations: iterations)
378
+ when :seed
379
+ ensure_seed_available!(format)
380
+ PKCS8.encode_der(@algorithm, @seed, format: :seed, passphrase: passphrase, iterations: iterations)
381
+ when :both
382
+ ensure_seed_available!(format)
383
+ PKCS8.encode_der(@algorithm, [@seed, @bytes], format: :both, passphrase: passphrase, iterations: iterations)
365
384
  else
366
385
  raise SerializationError, "Unsupported PKCS#8 private key format: #{format.inspect}"
367
386
  end
368
387
  end
369
388
 
370
- def to_pkcs8_pem(format: :expanded)
389
+ def to_pkcs8_pem(format: :expanded, passphrase: nil, iterations: PKCS8::ENCRYPTED_PKCS8_DEFAULT_ITERATIONS)
371
390
  case format
372
391
  when :expanded
373
- PKCS8.encode_pem(@algorithm, @bytes, format: :expanded)
374
- when :seed, :both
375
- raise SerializationError,
376
- "ML-DSA seed/both PKCS#8 export requires original seed material; use PQCrypto::PKCS8.encode_der/encode_pem directly"
392
+ PKCS8.encode_pem(@algorithm, @bytes, format: :expanded, passphrase: passphrase, iterations: iterations)
393
+ when :seed
394
+ ensure_seed_available!(format)
395
+ PKCS8.encode_pem(@algorithm, @seed, format: :seed, passphrase: passphrase, iterations: iterations)
396
+ when :both
397
+ ensure_seed_available!(format)
398
+ PKCS8.encode_pem(@algorithm, [@seed, @bytes], format: :both, passphrase: passphrase, iterations: iterations)
377
399
  else
378
400
  raise SerializationError, "Unsupported PKCS#8 private key format: #{format.inspect}"
379
401
  end
380
402
  end
381
403
 
382
- def sign(message)
383
- PQCrypto.__send__(Signature.send(:native_method_for, @algorithm, :sign), String(message).b, @bytes)
384
- rescue ArgumentError => e
385
- raise InvalidKeyError, e.message
404
+ def sign(message, context: "".b)
405
+ context = Signature.send(:validate_context!, context)
406
+ begin
407
+ PQCrypto.__send__(Signature.send(:native_method_for, @algorithm, :sign), String(message).b, @bytes, context)
408
+ rescue ArgumentError => e
409
+ raise InvalidKeyError, e.message
410
+ end
386
411
  end
387
412
 
388
413
  def sign_io(io, chunk_size: 1 << 20, context: "".b)
@@ -391,12 +416,13 @@ module PQCrypto
391
416
 
392
417
  def wipe!
393
418
  PQCrypto.secure_wipe(@bytes)
419
+ PQCrypto.secure_wipe(@seed) if @seed
394
420
  self
395
421
  end
396
422
 
397
423
  def ==(other)
398
424
  return false unless other.is_a?(SecretKey) && other.algorithm == algorithm
399
- PQCrypto.__send__(:native_ct_equals, other.to_bytes, @bytes)
425
+ PQCrypto.__send__(:native_ct_equals, other.send(:bytes_for_native), @bytes)
400
426
  end
401
427
 
402
428
  alias eql? ==
@@ -419,6 +445,17 @@ module PQCrypto
419
445
  expected = Signature.details(@algorithm).fetch(:secret_key_bytes)
420
446
  raise InvalidKeyError, "Invalid signature secret key length" unless @bytes.bytesize == expected
421
447
  end
448
+
449
+ def validate_seed_length!
450
+ expected = PKCS8::PRIVATE_KEY_CHOICES.fetch(@algorithm).fetch(:seed_bytes)
451
+ raise InvalidKeyError, "Invalid signature seed length" unless @seed.bytesize == expected
452
+ end
453
+
454
+ def ensure_seed_available!(format)
455
+ return if @seed
456
+
457
+ raise SerializationError, "ML-DSA #{format.inspect} PKCS#8 export requires original seed material"
458
+ end
422
459
  end
423
460
  end
424
461
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PQCrypto
4
- VERSION = "0.5.2"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/pq_crypto.rb CHANGED
@@ -37,6 +37,7 @@ require_relative "pq_crypto/pkcs8"
37
37
  require_relative "pq_crypto/kem"
38
38
  require_relative "pq_crypto/signature"
39
39
  require_relative "pq_crypto/hybrid_kem"
40
+ require_relative "pq_crypto/key"
40
41
 
41
42
  module PQCrypto
42
43
  SUITES = {
@@ -65,6 +66,7 @@ module PQCrypto
65
66
  hybrid_kem_encapsulate
66
67
  hybrid_kem_expand_secret_key
67
68
  hybrid_kem_expand_secret_key_object
69
+ hybrid_kem_expanded_secret_key_wipe
68
70
  hybrid_kem_decapsulate
69
71
  hybrid_kem_decapsulate_expanded
70
72
  hybrid_kem_decapsulate_expanded_object
@@ -164,25 +166,25 @@ module PQCrypto
164
166
  KEM_KEYPAIR_METHODS = {
165
167
  ml_kem_512: :native_ml_kem_512_keypair_from_seed,
166
168
  ml_kem_768: :native_ml_kem_keypair_from_seed,
167
- ml_kem_1024: :native_ml_kem_1024_keypair_from_seed,
169
+ ml_kem_1024: :native_ml_kem_1024_keypair_from_seed
168
170
  }.freeze
169
171
 
170
172
  KEM_ENCAPSULATE_METHODS = {
171
173
  ml_kem_512: :native_test_ml_kem_512_encapsulate_from_seed,
172
174
  ml_kem_768: :native_test_ml_kem_encapsulate_from_seed,
173
- ml_kem_1024: :native_test_ml_kem_1024_encapsulate_from_seed,
175
+ ml_kem_1024: :native_test_ml_kem_1024_encapsulate_from_seed
174
176
  }.freeze
175
177
 
176
178
  MLDSA_KEYPAIR_METHODS = {
177
179
  ml_dsa_44: :native_test_ml_dsa_44_keypair_from_seed,
178
180
  ml_dsa_65: :native_test_sign_keypair_from_seed,
179
- ml_dsa_87: :native_test_ml_dsa_87_keypair_from_seed,
181
+ ml_dsa_87: :native_test_ml_dsa_87_keypair_from_seed
180
182
  }.freeze
181
183
 
182
184
  MLDSA_SIGN_METHODS = {
183
185
  ml_dsa_44: :native_test_ml_dsa_44_sign_from_seed,
184
186
  ml_dsa_65: :native_test_sign_from_seed,
185
- ml_dsa_87: :native_test_ml_dsa_87_sign_from_seed,
187
+ ml_dsa_87: :native_test_ml_dsa_87_sign_from_seed
186
188
  }.freeze
187
189
 
188
190
  def self.ml_kem_keypair_from_seed(seed, algorithm: :ml_kem_768)
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pq_crypto
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haydarov
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-05-14 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rake
@@ -314,6 +315,7 @@ files:
314
315
  - lib/pq_crypto/errors.rb
315
316
  - lib/pq_crypto/hybrid_kem.rb
316
317
  - lib/pq_crypto/kem.rb
318
+ - lib/pq_crypto/key.rb
317
319
  - lib/pq_crypto/pkcs8.rb
318
320
  - lib/pq_crypto/serialization.rb
319
321
  - lib/pq_crypto/signature.rb
@@ -328,6 +330,7 @@ metadata:
328
330
  homepage_uri: https://github.com/roman-haidarov/pq_crypto
329
331
  source_code_uri: https://github.com/roman-haidarov/pq_crypto/tree/main
330
332
  changelog_uri: https://github.com/roman-haidarov/pq_crypto/blob/main/CHANGELOG.md
333
+ post_install_message:
331
334
  rdoc_options: []
332
335
  require_paths:
333
336
  - lib
@@ -335,14 +338,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
335
338
  requirements:
336
339
  - - ">="
337
340
  - !ruby/object:Gem::Version
338
- version: 3.4.0
341
+ version: '3.1'
339
342
  required_rubygems_version: !ruby/object:Gem::Requirement
340
343
  requirements:
341
344
  - - ">="
342
345
  - !ruby/object:Gem::Version
343
346
  version: '0'
344
347
  requirements: []
345
- rubygems_version: 3.6.7
348
+ rubygems_version: 3.3.27
349
+ signing_key:
346
350
  specification_version: 4
347
351
  summary: Primitive-first post-quantum cryptography for Ruby
348
352
  test_files: []