linzer 0.8.1.beta1 → 0.8.1.beta2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ced6e7bed237dbb28cefa79ed9289fa19db7510a7078c64315f92d50ae637b7d
4
- data.tar.gz: 291d1e92359fba0801ad5dfa543e6eb132c43c1f94686be5749462fcf3e18f2e
3
+ metadata.gz: 1bc1cb6232547c649f0c082b444d858632abcb7cd923a50c1bf55ea6da316034
4
+ data.tar.gz: 97da42478725e85ab12f9475f2808350dc76981c3f705a8e1479c57b7509f095
5
5
  SHA512:
6
- metadata.gz: 968bac61643099b34b73696193f8b5af9ab4489393b7d3d67d4f3fe70c35509c61e48acd659d4e78f101a8fabd7be65b549e87e6376bbaa146ff6cb755b894a8
7
- data.tar.gz: d901254dd56d886b96b8e4c6425958e63b97841f2682958cb1fd2b020b2799f2a81c15609f74aa8790f80692756d2647f9359f45729ac15f2ca1eaf1e20b0699
6
+ metadata.gz: 537e717e95afee1f2c32534ef294608020869b59aa4b07abdc58a629df11c74878390d0f0fa6202bbb7adf913ee70defde827f6f4c8e3c5d50d60c5fd159a1fe
7
+ data.tar.gz: 55337d62a400418a8b46bbd240a588e0fa6d66aef18df4c10938b0715919f8b236fb985a3ab0a082cb7fc10f13b27051f261daecf48a469b33ff11c5d7a19b7f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1.beta2] - 2026-08-14
4
+
5
+ - Add an OpenSSL-native ML-DSA backend, preferred by default over the
6
+ `ml_dsa` gem whenever this build's OpenSSL actually supports it (all
7
+ three FIPS 204 parameter sets: ML-DSA-44, ML-DSA-65, ML-DSA-87). Pass
8
+ `backend: :openssl` or `backend: :ml_dsa` to any
9
+ `generate_ml_dsa_*_key`/`new_ml_dsa_*_key` method to select
10
+ explicitly instead of relying on auto-selection; `key.backend`
11
+ reports which one produced a given key.
12
+ Pull request [#33](https://github.com/nomadium/linzer/pull/33).
13
+
14
+ - The `ml_dsa` gem is no longer a hard runtime dependency, it's an
15
+ optional backend now, a real behavior change from beta1. To keep
16
+ using it (e.g. on older OpenSSL builds, or explicitly via
17
+ `backend: :ml_dsa`), add it to your own Gemfile and
18
+ `require "linzer/ml_dsa/gem_key"` before use.
19
+
3
20
  ## [0.8.1.beta1] - 2026-08-08
4
21
 
5
22
  - Add ML-DSA-44, ML-DSA-65, and ML-DSA-87 support following the C2SP
data/README.md CHANGED
@@ -556,6 +556,45 @@ Linzer currently supports the following signature algorithms:
556
556
  - ECDSA (P-256 and P-384 curves).
557
557
  - ML-DSA-44, ML-DSA-65, and ML-DSA-87 ([C2SP profile](https://c2sp.org/httpsig-pq))
558
558
 
559
+ ### ML-DSA
560
+
561
+ ML-DSA (FIPS 204, post-quantum) is backed by two interchangeable
562
+ implementations. By default, Linzer uses OpenSSL 3.5+'s native ML-DSA
563
+ support when available, with no extra dependency:
564
+
565
+ ```ruby
566
+ key = Linzer.generate_ml_dsa_44_key # or _65_key / _87_key
567
+ # => #<Linzer::MLDSA::OpenSSLKey:0x00000fe13e9bd208
568
+ # or load an existing key with:
569
+ # key = Linzer.new_ml_dsa_44_key(IO.read("key"), "mykeyid")
570
+
571
+ key.backend # => :openssl, or :ml_dsa if this build fell back to it
572
+ ```
573
+
574
+ Older OpenSSL builds aren't the only reason to reach for it: even on a fully
575
+ capable build, you can opt into the [`ml_dsa`](https://rubygems.org/gems/ml_dsa)
576
+ gem (a C extension bundling the PQClean implementation) explicitly via
577
+ `backend: :ml_dsa`, for example, if you'd rather not depend on OpenSSL's ML-DSA
578
+ support for a given deployment. Either way, you need to add the gem to your own
579
+ Gemfile:
580
+
581
+ ```ruby
582
+ # Gemfile
583
+ gem "ml_dsa"
584
+ ```
585
+
586
+ ```ruby
587
+ require "linzer/ml_dsa/gem_key"
588
+
589
+ key = Linzer.generate_ml_dsa_44_key
590
+ ```
591
+
592
+ Pass `backend: :openssl` or `backend: :ml_dsa` to any `generate_ml_dsa_*`/
593
+ `new_ml_dsa_*` method to force a specific implementation instead of
594
+ relying on auto-selection.
595
+
596
+ ### JSON Web Signature (JWS) algorithms
597
+
559
598
  Of the JSON Web Signature (JWS) algorithms mentioned in RFC 9421,
560
599
  only Ed25519 is currently supported. Support for additional
561
600
  algorithms is planned and should be straightforward to add.
data/lib/linzer/helper.rb CHANGED
@@ -60,7 +60,7 @@ module Linzer
60
60
  ctx = Signature::Context.new(
61
61
  message: Message.new(request_or_response),
62
62
  key: key,
63
- label: label,
63
+ label: label || Signer::DEFAULT_LABEL,
64
64
  components: Array(components),
65
65
  params: Hash(params)
66
66
  )
data/lib/linzer/jws.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require "jwt"
4
4
  require "jwt/eddsa"
5
5
  require "ed25519"
6
+ require "digest"
7
+ require "base64"
6
8
 
7
9
  module Linzer
8
10
  # JSON Web Signature (JWS) compatible key support.
@@ -101,6 +103,40 @@ module Linzer
101
103
  algo.verify(data: data, signature: signature, verification_key: verify_key)
102
104
  end
103
105
 
106
+ # Computes the RFC 7638 JWK SHA-256 Thumbprint for this key's public
107
+ # material.
108
+ #
109
+ # This is computed directly from the exported JWK rather than
110
+ # delegating to the underlying jwt-eddsa gem's own thumbprint/kid
111
+ # generation: jwt-eddsa (<= 0.9.0) has a bug where its OKP JWK class
112
+ # computes that value over the wrong members (an RSA-shaped {kty, n,
113
+ # x} instead of the RFC 8037-correct {crv, kty, x}), which silently
114
+ # produces a keyid that a spec-compliant verifier will reject.
115
+ #
116
+ # @return [String] base64url-encoded (no padding) SHA-256 thumbprint
117
+ # @raise [Error] if this key's JWK "kty" is not supported
118
+ #
119
+ # @see https://www.rfc-editor.org/rfc/rfc7638 RFC 7638 - JSON Web Key (JWK) Thumbprint
120
+ # @see https://www.rfc-editor.org/rfc/rfc8037 RFC 8037 - EdDSA for JWS/JWK
121
+ def jwk_thumbprint
122
+ # XXX: drop this method custom implementation and just
123
+ # return material.key_digest
124
+ # once https://github.com/jwt/ruby-jwt-eddsa/pull/26 is resolved
125
+ #
126
+ exported = material.export
127
+
128
+ members =
129
+ case exported[:kty]
130
+ when "OKP"
131
+ {crv: exported[:crv], kty: exported[:kty], x: exported[:x]}
132
+ else
133
+ raise Error, "Unsupported JWK kty for thumbprint: #{exported[:kty]}"
134
+ end
135
+
136
+ digest = Digest::SHA256.digest(JWT::JSON.generate(members))
137
+ Base64.urlsafe_encode64(digest, padding: false)
138
+ end
139
+
104
140
  private
105
141
 
106
142
  # @return [Boolean] true if this key can verify signatures
@@ -244,53 +244,69 @@ module Linzer
244
244
  # Generates an ML-DSA-44 key pair.
245
245
  #
246
246
  # @param key_id [String, nil] Optional key identifier
247
- # @return [MLDSA::Key] A new ML-DSA-44 key pair
247
+ # @param backend [Symbol] :auto (default, prefers OpenSSL when this
248
+ # build supports it, see {Linzer::MLDSA.openssl_supported?}
249
+ # falling back to the ml_dsa gem otherwise), :openssl, or :ml_dsa
250
+ # @return [MLDSA::OpenSSLKey, MLDSA::GemKey] A new ML-DSA-44 key pair
251
+ # @raise [Error] If the requested backend can't actually be used
248
252
  # @see https://c2sp.org/httpsig-pq C2SP post-quantum HTTP signatures
249
- def generate_ml_dsa_44_key(key_id = nil)
250
- generate_ml_dsa_key("ml-dsa-44", key_id)
253
+ def generate_ml_dsa_44_key(key_id = nil, backend: :auto)
254
+ generate_ml_dsa_key("ml-dsa-44", key_id, backend)
251
255
  end
252
256
 
253
257
  # Generates an ML-DSA-65 key pair.
254
258
  #
255
259
  # @param key_id [String, nil] Optional key identifier
256
- # @return [MLDSA::Key] A new ML-DSA-65 key pair
257
- def generate_ml_dsa_65_key(key_id = nil)
258
- generate_ml_dsa_key("ml-dsa-65", key_id)
260
+ # @param backend [Symbol] :auto, :openssl, or :ml_dsa, see
261
+ # {#generate_ml_dsa_44_key}
262
+ # @return [MLDSA::OpenSSLKey, MLDSA::GemKey] A new ML-DSA-65 key pair
263
+ # @raise [Error] If the requested backend can't actually be used
264
+ def generate_ml_dsa_65_key(key_id = nil, backend: :auto)
265
+ generate_ml_dsa_key("ml-dsa-65", key_id, backend)
259
266
  end
260
267
 
261
268
  # Generates an ML-DSA-87 key pair.
262
269
  #
263
270
  # @param key_id [String, nil] Optional key identifier
264
- # @return [MLDSA::Key] A new ML-DSA-87 key pair
265
- def generate_ml_dsa_87_key(key_id = nil)
266
- generate_ml_dsa_key("ml-dsa-87", key_id)
271
+ # @param backend [Symbol] :auto, :openssl, or :ml_dsa, see
272
+ # {#generate_ml_dsa_44_key}
273
+ # @return [MLDSA::OpenSSLKey, MLDSA::GemKey] A new ML-DSA-87 key pair
274
+ # @raise [Error] If the requested backend can't actually be used
275
+ def generate_ml_dsa_87_key(key_id = nil, backend: :auto)
276
+ generate_ml_dsa_key("ml-dsa-87", key_id, backend)
267
277
  end
268
278
 
269
279
  # Loads a raw, DER, or PEM ML-DSA-44 private or public key.
270
280
  #
271
281
  # @param material [String, MlDsa::SecretKey, MlDsa::PublicKey] Key material
272
282
  # @param key_id [String, nil] Optional key identifier
273
- # @return [MLDSA::Key] The loaded key
274
- def new_ml_dsa_44_key(material, key_id = nil)
275
- new_ml_dsa_key(material, "ml-dsa-44", key_id)
283
+ # @param backend [Symbol] :auto, :openssl, or :ml_dsa, see
284
+ # {#generate_ml_dsa_44_key}
285
+ # @return [MLDSA::OpenSSLKey, MLDSA::GemKey] The loaded key
286
+ def new_ml_dsa_44_key(material, key_id = nil, backend: :auto)
287
+ new_ml_dsa_key(material, "ml-dsa-44", key_id, backend)
276
288
  end
277
289
 
278
290
  # Loads a raw, DER, or PEM ML-DSA-65 private or public key.
279
291
  #
280
292
  # @param material [String, MlDsa::SecretKey, MlDsa::PublicKey] Key material
281
293
  # @param key_id [String, nil] Optional key identifier
282
- # @return [MLDSA::Key] The loaded key
283
- def new_ml_dsa_65_key(material, key_id = nil)
284
- new_ml_dsa_key(material, "ml-dsa-65", key_id)
294
+ # @param backend [Symbol] :auto, :openssl, or :ml_dsa, see
295
+ # {#generate_ml_dsa_44_key}
296
+ # @return [MLDSA::OpenSSLKey, MLDSA::GemKey] The loaded key
297
+ def new_ml_dsa_65_key(material, key_id = nil, backend: :auto)
298
+ new_ml_dsa_key(material, "ml-dsa-65", key_id, backend)
285
299
  end
286
300
 
287
301
  # Loads a raw, DER, or PEM ML-DSA-87 private or public key.
288
302
  #
289
303
  # @param material [String, MlDsa::SecretKey, MlDsa::PublicKey] Key material
290
304
  # @param key_id [String, nil] Optional key identifier
291
- # @return [MLDSA::Key] The loaded key
292
- def new_ml_dsa_87_key(material, key_id = nil)
293
- new_ml_dsa_key(material, "ml-dsa-87", key_id)
305
+ # @param backend [Symbol] :auto, :openssl, or :ml_dsa, see
306
+ # {#generate_ml_dsa_44_key}
307
+ # @return [MLDSA::OpenSSLKey, MLDSA::GemKey] The loaded key
308
+ def new_ml_dsa_87_key(material, key_id = nil, backend: :auto)
309
+ new_ml_dsa_key(material, "ml-dsa-87", key_id, backend)
294
310
  end
295
311
 
296
312
  # Generates a new JWS key for the specified algorithm.
@@ -324,22 +340,88 @@ module Linzer
324
340
 
325
341
  private
326
342
 
327
- def generate_ml_dsa_key(algorithm, key_id)
343
+ def generate_ml_dsa_key(algorithm, key_id, backend)
344
+ case resolve_ml_dsa_backend(algorithm, backend)
345
+ when :openssl
346
+ material = OpenSSL::PKey.generate_key(algorithm.upcase)
347
+ Linzer::MLDSA::OpenSSLKey.new(material, id: key_id, algorithm: algorithm)
348
+ when :ml_dsa
349
+ ensure_ml_dsa_gem_key_available!
350
+ generate_ml_dsa_key_via_gem(algorithm, key_id)
351
+ end
352
+ end
353
+
354
+ def new_ml_dsa_key(material, algorithm, key_id, backend)
355
+ case resolve_ml_dsa_backend(algorithm, backend)
356
+ when :openssl
357
+ key = Linzer::MLDSA.deserialize_raw_or_encoded_key(material, algorithm)
358
+ Linzer::MLDSA::OpenSSLKey.new(key, id: key_id, algorithm: algorithm)
359
+ when :ml_dsa
360
+ ensure_ml_dsa_gem_key_available!
361
+ new_ml_dsa_key_via_gem(material, algorithm, key_id)
362
+ end
363
+ end
364
+
365
+ # NOTE: ensure_ml_dsa_gem_key_available! must run in the caller, not
366
+ # here. This method's own `rescue MlDsa::Error` would need MlDsa
367
+ # to be defined just to evaluate, defeating the guard.
368
+ def generate_ml_dsa_key_via_gem(algorithm, key_id)
328
369
  parameter_set = Linzer::MLDSA::ALGORITHMS.fetch(algorithm)
329
370
  pair = MlDsa.keygen(parameter_set)
330
- Linzer::MLDSA::Key.new(pair.secret_key, id: key_id, algorithm: algorithm)
371
+ Linzer::MLDSA::GemKey.new(pair.secret_key, id: key_id, algorithm: algorithm)
331
372
  rescue MlDsa::Error, ArgumentError, TypeError => e
332
373
  raise Linzer::Error, e.message, cause: e
333
374
  end
334
375
 
335
- def new_ml_dsa_key(material, algorithm, key_id)
376
+ # NOTE: see generate_ml_dsa_key_via_gem, same reason.
377
+ def new_ml_dsa_key_via_gem(material, algorithm, key_id)
336
378
  parameter_set = Linzer::MLDSA::ALGORITHMS.fetch(algorithm)
337
379
  key = deserialize_ml_dsa_key(material, parameter_set)
338
- Linzer::MLDSA::Key.new(key, id: key_id, algorithm: algorithm)
380
+ Linzer::MLDSA::GemKey.new(key, id: key_id, algorithm: algorithm)
339
381
  rescue MlDsa::Error, ArgumentError, TypeError => e
340
382
  raise Linzer::Error, e.message, cause: e
341
383
  end
342
384
 
385
+ # Resolves a `backend:` argument (:auto, :openssl, or :ml_dsa) to the
386
+ # concrete backend to actually use for a given algorithm.
387
+ #
388
+ # :auto trusts Linzer::MLDSA.openssl_supported?(algorithm) completely,
389
+ # the single source of truth for whether OpenSSL both can and has
390
+ # been wired up to handle this algorithm. An explicit :openssl request
391
+ # is held to the same standard: it's rejected up front, with a clear
392
+ # error, rather than silently attempted for an algorithm OpenSSLKey
393
+ # doesn't support (e.g. an unimplemented future parameter set, or a
394
+ # build without ML-DSA enabled).
395
+ #
396
+ # @return [Symbol] :openssl or :ml_dsa
397
+ # @raise [Error] If backend is :openssl but unavailable for algorithm,
398
+ # or if backend is anything other than :auto/:openssl/:ml_dsa
399
+ def resolve_ml_dsa_backend(algorithm, backend)
400
+ case backend
401
+ when :auto
402
+ Linzer::MLDSA.openssl_supported?(algorithm) ? :openssl : :ml_dsa
403
+ when :openssl
404
+ unless Linzer::MLDSA.openssl_supported?(algorithm)
405
+ raise Linzer::Error, "OpenSSL-backed ML-DSA is not available for #{algorithm} on this build"
406
+ end
407
+ :openssl
408
+ when :ml_dsa
409
+ :ml_dsa
410
+ else
411
+ raise Linzer::Error, "Unknown ML-DSA backend: #{backend.inspect} (expected :auto, :openssl, or :ml_dsa)"
412
+ end
413
+ end
414
+
415
+ # @raise [Error] If Linzer::MLDSA::GemKey isn't loaded (the ml_dsa
416
+ # gem backend is opt-in, see lib/linzer/ml_dsa/gem_key.rb)
417
+ def ensure_ml_dsa_gem_key_available!
418
+ return if defined?(Linzer::MLDSA::GemKey)
419
+
420
+ raise Linzer::Error,
421
+ "ML-DSA gem backend not available: " \
422
+ 'require "linzer/ml_dsa/gem_key" first'
423
+ end
424
+
343
425
  def deserialize_ml_dsa_key(material, parameter_set)
344
426
  return material if material.is_a?(MlDsa::PublicKey) || material.is_a?(MlDsa::SecretKey)
345
427
  unless material.is_a?(String)
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "ml_dsa"
5
+ rescue LoadError
6
+ raise Linzer::Error, "ml_dsa gem must be installed to use this feature."
7
+ end
8
+
9
+ module Linzer
10
+ module MLDSA
11
+ ALGORITHMS = {
12
+ "ml-dsa-44" => MlDsa::ML_DSA_44,
13
+ "ml-dsa-65" => MlDsa::ML_DSA_65,
14
+ "ml-dsa-87" => MlDsa::ML_DSA_87
15
+ }.freeze
16
+
17
+ # ML-DSA signing/verification backed by the `ml_dsa` gem (a C
18
+ # extension bundling the PQClean implementation).
19
+ #
20
+ # Supports all three FIPS 204 parameter sets. Optional: not required
21
+ # by `linzer.rb` itself, and not a runtime dependency of the gemspec,
22
+ # callers who want this backend must add `ml_dsa` to their own
23
+ # Gemfile and `require "linzer/ml_dsa/gem_key"` themselves, which
24
+ # requires `ml_dsa` in turn.
25
+ #
26
+ # @see Linzer::MLDSA::OpenSSLKey for the dependency-free alternative
27
+ # backed directly by OpenSSL 3.5+, also supporting all three
28
+ # parameter sets.
29
+ class GemKey < Linzer::Key
30
+ attr_reader :algorithm
31
+
32
+ def initialize(material, params = {})
33
+ @algorithm = String(params.fetch(:algorithm))
34
+ super
35
+ end
36
+
37
+ # Validates that the HTTP `alg` parameter matches this key's parameter set.
38
+ #
39
+ # @param parameters [Hash] HTTP signature parameters
40
+ # @return [true] If `alg` is absent or matches this key
41
+ # @raise [VerifyError] If `alg` selects another ML-DSA parameter set
42
+ def validate_signature_parameters(parameters)
43
+ supplied_algorithm = parameters["alg"] || parameters[:alg]
44
+ return true if supplied_algorithm.nil? || supplied_algorithm == algorithm
45
+
46
+ raise VerifyError,
47
+ "Signature algorithm #{supplied_algorithm} does not match key algorithm #{algorithm}"
48
+ end
49
+
50
+ # Signs an RFC 9421 signature base with an empty FIPS 204 context.
51
+ #
52
+ # @param data [String] Signature base bytes
53
+ # @return [String] Raw FIPS 204 signature bytes
54
+ # @raise [SigningError] If private key material is unavailable
55
+ def sign(data)
56
+ validate_signing_key
57
+ material.sign(data, context: "")
58
+ rescue MlDsa::Error => e
59
+ raise SigningError, e.message, cause: e
60
+ end
61
+
62
+ # Verifies an RFC 9421 signature base with an empty FIPS 204 context.
63
+ #
64
+ # @param signature [String] Raw FIPS 204 signature bytes
65
+ # @param data [String] Signature base bytes
66
+ # @return [Boolean] Whether the signature is valid
67
+ # @raise [VerifyError] If public key material is unavailable
68
+ def verify(signature, data)
69
+ validate_verify_key
70
+ return false unless signature.is_a?(String)
71
+ return false unless signature.bytesize == parameter_set.signature_bytes
72
+
73
+ verification_material.verify(data, signature, context: "")
74
+ rescue MlDsa::Error, ArgumentError, TypeError
75
+ false
76
+ end
77
+
78
+ # @return [Symbol] :ml_dsa -- which backend produced this key
79
+ def backend
80
+ :ml_dsa
81
+ end
82
+
83
+ private
84
+
85
+ def validate
86
+ super
87
+ expected = ALGORITHMS[algorithm]
88
+ raise Error, "Unsupported ML-DSA algorithm: #{algorithm}" unless expected
89
+
90
+ valid_type = material.is_a?(MlDsa::PublicKey) || material.is_a?(MlDsa::SecretKey)
91
+ raise Error, "Invalid ML-DSA key material" unless valid_type
92
+
93
+ return if material.param_set == expected
94
+
95
+ raise Error,
96
+ "ML-DSA key parameter set #{material.param_set} does not match #{algorithm}"
97
+ end
98
+
99
+ def parameter_set
100
+ ALGORITHMS.fetch(algorithm)
101
+ end
102
+
103
+ def verification_material
104
+ return material if material.is_a?(MlDsa::PublicKey)
105
+ return material.public_key if material.public_key
106
+
107
+ raise VerifyError, "Public key is needed!"
108
+ end
109
+
110
+ def compute_private?
111
+ material.is_a?(MlDsa::SecretKey)
112
+ end
113
+
114
+ def compute_public?
115
+ material.is_a?(MlDsa::PublicKey) ||
116
+ (material.is_a?(MlDsa::SecretKey) && !material.public_key.nil?)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ module MLDSA
5
+ # NIST OIDs for ML-DSA (id-ml-dsa-44/65/87), used both by OpenSSL's own
6
+ # PEM/DER encoding and by {wrap_raw_public_key}/{wrap_raw_private_key}
7
+ # below when reconstructing a key from raw FIPS 204 bytes. Verified
8
+ # against real OpenSSL 3.5+ output (decoded from a freshly generated
9
+ # key's own `public_to_der`), not taken from documentation alone.
10
+ # @return [Hash{String => String}]
11
+ OPENSSL_OIDS = {
12
+ "ml-dsa-44" => "2.16.840.1.101.3.4.3.17",
13
+ "ml-dsa-65" => "2.16.840.1.101.3.4.3.18",
14
+ "ml-dsa-87" => "2.16.840.1.101.3.4.3.19"
15
+ }.freeze
16
+ private_constant :OPENSSL_OIDS
17
+
18
+ # Linzer algorithm identifiers (lowercase, e.g. "ml-dsa-44") that this
19
+ # OpenSSL-backed implementation actually has construction/OID support
20
+ # for today. {openssl_supported?} treats anything outside this set as
21
+ # unsupported without ever asking OpenSSL about it.
22
+ # @return [Array<String>]
23
+ IMPLEMENTED_ALGORITHMS = %w[ml-dsa-44 ml-dsa-65 ml-dsa-87].freeze
24
+ private_constant :IMPLEMENTED_ALGORITHMS
25
+
26
+ # FIPS 204 raw public-key sizes per parameter set, used to sniff raw
27
+ # key material in {deserialize_raw_or_encoded_key} the same way
28
+ # {GemKey} does for the gem backend.
29
+ # @return [Hash{String => Integer}]
30
+ RAW_PUBLIC_KEY_BYTES = {
31
+ "ml-dsa-44" => 1312,
32
+ "ml-dsa-65" => 1952,
33
+ "ml-dsa-87" => 2592
34
+ }.freeze
35
+ private_constant :RAW_PUBLIC_KEY_BYTES
36
+
37
+ # FIPS 204 raw (expanded, seed-free) private-key sizes per parameter set.
38
+ # @return [Hash{String => Integer}]
39
+ RAW_PRIVATE_KEY_BYTES = {
40
+ "ml-dsa-44" => 2560,
41
+ "ml-dsa-65" => 4032,
42
+ "ml-dsa-87" => 4896
43
+ }.freeze
44
+ private_constant :RAW_PRIVATE_KEY_BYTES
45
+
46
+ # ML-DSA (FIPS 204) signing/verification backed directly by OpenSSL
47
+ # 3.5+, with no additional gem dependency. Supports all three
48
+ # parameter sets (ML-DSA-44/65/87).
49
+ #
50
+ # Like Ed25519, ML-DSA is a "pure"/digest-less signature scheme: the
51
+ # RFC 9421 signature base is signed directly, with no prehashing.
52
+ #
53
+ # @note Requires OpenSSL 3.5+ with ML-DSA signature algorithms enabled.
54
+ # Some distributions ship OpenSSL 3.5+ with these disabled by crypto
55
+ # policy (see https://github.com/ruby/openssl/issues/1075), so callers
56
+ # should be prepared for {OpenSSL::PKey::PKeyError} on unsupported
57
+ # builds even when the OpenSSL version alone looks sufficient.
58
+ #
59
+ # @see Linzer::MLDSA::GemKey for the `ml_dsa`-gem-backed alternative
60
+ # @see https://github.com/C2SP/C2SP/blob/httpsig-pq/v0.2.0/httpsig-pq.md
61
+ # C2SP httpsig-pq: Post-Quantum Algorithms for HTTP Message Signatures
62
+ # @see https://csrc.nist.gov/pubs/fips/204/final FIPS 204
63
+ class OpenSSLKey < Linzer::Key
64
+ # @return [String] The FIPS 204 parameter set this key was
65
+ # constructed for, e.g. `"ml-dsa-44"`
66
+ attr_reader :algorithm
67
+
68
+ # @param material [OpenSSL::PKey::PKey] The underlying OpenSSL key
69
+ # @param params [Hash] Additional key parameters
70
+ # @option params [String] :algorithm Required. One of
71
+ # `"ml-dsa-44"`/`"ml-dsa-65"`/`"ml-dsa-87"`
72
+ # @option params [String] :id The key identifier (keyid)
73
+ def initialize(material, params = {})
74
+ @algorithm = String(params.fetch(:algorithm))
75
+ super
76
+ end
77
+
78
+ # Validates that the HTTP `alg` parameter matches this key's algorithm.
79
+ #
80
+ # @param parameters [Hash] HTTP signature parameters
81
+ # @return [true] If `alg` is absent or matches this key
82
+ # @raise [VerifyError] If `alg` selects a different algorithm
83
+ def validate_signature_parameters(parameters)
84
+ supplied_algorithm = parameters["alg"] || parameters[:alg]
85
+ return true if supplied_algorithm.nil? || supplied_algorithm == algorithm
86
+
87
+ raise VerifyError,
88
+ "Signature algorithm #{supplied_algorithm} does not match key algorithm #{algorithm}"
89
+ end
90
+
91
+ # Signs data using the ML-DSA private key.
92
+ #
93
+ # @param data [String] The data to sign (typically the signature base)
94
+ # @return [String] The FIPS 204 signature
95
+ # @raise [SigningError] If this key does not contain private key material
96
+ def sign(data)
97
+ validate_signing_key
98
+ material.sign(nil, data)
99
+ end
100
+
101
+ # Verifies a signature using the ML-DSA public key.
102
+ #
103
+ # @param signature [String] The signature bytes to verify
104
+ # @param data [String] The data that was signed
105
+ # @return [Boolean] true if the signature is valid, false otherwise
106
+ # @raise [VerifyError] If this key does not contain public key material
107
+ def verify(signature, data)
108
+ validate_verify_key
109
+ material.verify(nil, signature, data)
110
+ end
111
+
112
+ # @return [Symbol] :openssl -- which backend produced this key
113
+ def backend
114
+ :openssl
115
+ end
116
+
117
+ private
118
+
119
+ # Cross-checks the underlying OpenSSL key material against the
120
+ # claimed {algorithm}. Only actually able to catch a mismatch when
121
+ # `material` came in as PEM/DER (via {OpenSSL::PKey.read}) for a
122
+ # *different* ML-DSA parameter set than requested, material built
123
+ # via {Linzer::MLDSA.deserialize_raw_or_encoded_key}'s raw-byte path
124
+ # or `OpenSSL::PKey.generate_key(algorithm.upcase)` is already
125
+ # guaranteed consistent by construction, so this is a no-op for
126
+ # those (cheap: `public_to_der` on an already-parsed key, no network
127
+ # or extra crypto). Best-effort: if the material's own OID can't be
128
+ # determined at all, doesn't fail the whole key over it, something
129
+ # downstream (sign/verify) will surface a real problem regardless.
130
+ # @raise [Error] If key material is nil, or its actual algorithm
131
+ # doesn't match {algorithm}
132
+ def validate
133
+ super
134
+ expected_oid = OPENSSL_OIDS.fetch(algorithm) { raise Error, "Unsupported ML-DSA algorithm: #{algorithm}" }
135
+ actual_oid = material_openssl_oid
136
+ return if actual_oid.nil? || actual_oid == expected_oid
137
+
138
+ raise Error, "ML-DSA key material (#{actual_oid}) does not match algorithm #{algorithm}"
139
+ end
140
+
141
+ # @return [String, nil] The dotted OID of `material`'s own
142
+ # AlgorithmIdentifier, or nil if it can't be determined
143
+ def material_openssl_oid
144
+ spki = OpenSSL::ASN1.decode(material.public_to_der)
145
+ spki.value[0].value[0].oid
146
+ rescue OpenSSL::ASN1::ASN1Error, OpenSSL::PKey::PKeyError
147
+ nil
148
+ end
149
+
150
+ # @return [Boolean] true if this key contains public key material
151
+ def compute_public?
152
+ has_pem_public?
153
+ end
154
+
155
+ # @return [Boolean] true if this key contains private key material
156
+ def compute_private?
157
+ has_pem_private?
158
+ end
159
+ end
160
+
161
+ class << self
162
+ # Checks whether this OpenSSL build can actually perform ML-DSA
163
+ # signing/verification for the given algorithm.
164
+ #
165
+ # Returns true only when the algorithm is *both* something this
166
+ # OpenSSL-backed implementation has actually implemented (see
167
+ # {IMPLEMENTED_ALGORITHMS}) *and* something the underlying OpenSSL
168
+ # library itself supports. This is needed mostly because a
169
+ # version-number check alone isn't reliable and some distributions ship
170
+ # OpenSSL 3.5+ with ML-DSA disabled by crypto policy (see
171
+ # https://github.com/ruby/openssl/issues/1075), so this actually
172
+ # attempts a throwaway key generation rather than inspecting
173
+ # `OpenSSL::OPENSSL_VERSION`.
174
+ #
175
+ # Memoized per algorithm after the first check, so repeated calls are
176
+ # free. Unknown or not-yet-implemented algorithm identifiers return
177
+ # `false` rather than raising.
178
+ #
179
+ # @param algorithm [String] Linzer's lowercase algorithm identifier,
180
+ # e.g. `"ml-dsa-44"`
181
+ # @return [Boolean]
182
+ def openssl_supported?(algorithm)
183
+ cache = (@openssl_supported ||= {})
184
+ return cache[algorithm] if cache.key?(algorithm)
185
+
186
+ cache[algorithm] =
187
+ if IMPLEMENTED_ALGORITHMS.include?(algorithm)
188
+ begin
189
+ OpenSSL::PKey.generate_key(algorithm.upcase)
190
+ true
191
+ rescue OpenSSL::PKey::PKeyError
192
+ false
193
+ end
194
+ else
195
+ false
196
+ end
197
+ end
198
+
199
+ # Builds an OpenSSL key from ML-DSA material of unknown shape: raw
200
+ # FIPS 204 bytes for `algorithm` (sniffed by exact byte length, the
201
+ # same approach {Linzer::MLDSA::GemKey} uses for the gem backend) or
202
+ # an OpenSSL-encoded PEM/DER key, handled as a fallback. Sniffing is
203
+ # scoped to `algorithm`'s own raw sizes, not all three parameter
204
+ # sets' sizes at once, material sized for a *different* parameter
205
+ # set than requested falls through to the `OpenSSL::PKey.read`
206
+ # fallback and fails there (raw bytes aren't valid PEM/DER), rather
207
+ # than silently being accepted and mislabeled.
208
+ #
209
+ # @param material [String] Raw FIPS 204 bytes, or a PEM/DER-encoded key
210
+ # @param algorithm [String] Linzer's lowercase algorithm identifier,
211
+ # e.g. `"ml-dsa-44"`
212
+ # @return [OpenSSL::PKey::PKey]
213
+ # @api private
214
+ def deserialize_raw_or_encoded_key(material, algorithm)
215
+ case material.bytesize
216
+ when RAW_PUBLIC_KEY_BYTES.fetch(algorithm)
217
+ wrap_raw_public_key(material, algorithm)
218
+ when RAW_PRIVATE_KEY_BYTES.fetch(algorithm)
219
+ wrap_raw_private_key(material, algorithm)
220
+ else
221
+ OpenSSL::PKey.read(material)
222
+ end
223
+ end
224
+
225
+ private
226
+
227
+ # Reconstructs an OpenSSL key from a raw FIPS 204 ML-DSA public key.
228
+ #
229
+ # The `openssl` gem does not yet accept ML-DSA algorithm names in
230
+ # {OpenSSL::PKey.new_raw_public_key} (there is no upstream issue
231
+ # tracking this as of this writing), so this wraps the raw bytes in a
232
+ # minimal DER SubjectPublicKeyInfo structure that {OpenSSL::PKey.read}
233
+ # does accept. Verified byte-identical to OpenSSL's own
234
+ # `public_to_der` output for ml-dsa-44; for ml-dsa-65/87, verified
235
+ # via a full raw-bytes-in/sign/verify round trip instead (a
236
+ # byte-level mismatch would fail that too). Tested against OpenSSL 3.5+
237
+ # in both cases.
238
+ #
239
+ # @param raw_public_key [String] Raw FIPS 204 public key bytes
240
+ # @param algorithm [String] Linzer's lowercase algorithm identifier
241
+ # @return [OpenSSL::PKey::PKey]
242
+ def wrap_raw_public_key(raw_public_key, algorithm)
243
+ spki = OpenSSL::ASN1::Sequence.new([
244
+ ml_dsa_algorithm_identifier(algorithm),
245
+ OpenSSL::ASN1::BitString.new(raw_public_key)
246
+ ])
247
+ OpenSSL::PKey.read(spki.to_der)
248
+ end
249
+
250
+ # Builds an OpenSSL key from a raw FIPS 204 ML-DSA private key.
251
+ #
252
+ # {OpenSSL::PKey.new_raw_private_key} doesn't accept ML-DSA algorithm
253
+ # names yet, and unlike the public key, a fixed DER prefix won't work
254
+ # here: OpenSSL's PKCS8 encoding embeds a per-key 32-byte seed
255
+ # alongside the expanded key. This builds the seed-free "expandedKey"
256
+ # alternative of the ML-DSA private key CHOICE instead (an untagged
257
+ # OCTET STRING), which carries no per-key data.
258
+ #
259
+ # @param raw_private_key [String] Raw FIPS 204 private key bytes
260
+ # @param algorithm [String] Linzer's lowercase algorithm identifier
261
+ # @return [OpenSSL::PKey::PKey]
262
+ def wrap_raw_private_key(raw_private_key, algorithm)
263
+ expanded_key_choice = OpenSSL::ASN1::OctetString.new(raw_private_key).to_der
264
+ one_asymmetric_key = OpenSSL::ASN1::Sequence.new([
265
+ OpenSSL::ASN1::Integer(0),
266
+ ml_dsa_algorithm_identifier(algorithm),
267
+ OpenSSL::ASN1::OctetString.new(expanded_key_choice)
268
+ ])
269
+ OpenSSL::PKey.read(one_asymmetric_key.to_der)
270
+ end
271
+
272
+ # @param algorithm [String] Linzer's lowercase algorithm identifier
273
+ # @return [OpenSSL::ASN1::Sequence] the ML-DSA AlgorithmIdentifier
274
+ def ml_dsa_algorithm_identifier(algorithm)
275
+ OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId(OPENSSL_OIDS.fetch(algorithm))])
276
+ end
277
+ end
278
+ end
279
+ end
data/lib/linzer/ml_dsa.rb CHANGED
@@ -1,102 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "ml_dsa/openssl_key"
4
+
3
5
  module Linzer
4
6
  # ML-DSA support for HTTP Message Signatures as specified by https://c2sp.org/httpsig-pq
5
7
  #
8
+ # Two independent backends live under this namespace, both supporting
9
+ # all three FIPS 204 parameter sets (ML-DSA-44/65/87):
10
+ #
11
+ # - {Linzer::MLDSA::OpenSSLKey} -- backed directly by OpenSSL 3.5+, no
12
+ # extra gem dependency. Always loaded by this file.
13
+ # - {Linzer::MLDSA::GemKey} -- backed by the `ml_dsa` gem. Optional:
14
+ # `require "linzer/ml_dsa/gem_key"` yourself to use it (which
15
+ # requires `ml_dsa` in turn).
16
+ #
17
+ # `Linzer.generate_ml_dsa_*_key`/`Linzer.new_ml_dsa_*_key` dispatch
18
+ # between them via a `backend:` keyword (:auto, :openssl, or :ml_dsa),
19
+ # preferring OpenSSL when this build actually supports it.
20
+ #
6
21
  # @see https://c2sp.org/httpsig-pq C2SP post-quantum HTTP signatures
7
22
  # @see https://csrc.nist.gov/pubs/fips/204/final FIPS 204
8
23
  module MLDSA
9
- ALGORITHMS = {
10
- "ml-dsa-44" => MlDsa::ML_DSA_44,
11
- "ml-dsa-65" => MlDsa::ML_DSA_65,
12
- "ml-dsa-87" => MlDsa::ML_DSA_87
13
- }.freeze
14
-
15
- # An ML-DSA signing or verification key.
16
- class Key < Linzer::Key
17
- attr_reader :algorithm
18
-
19
- def initialize(material, params = {})
20
- @algorithm = String(params.fetch(:algorithm))
21
- super
22
- end
23
-
24
- # Validates that the HTTP `alg` parameter matches this key's parameter set.
25
- #
26
- # @param parameters [Hash] HTTP signature parameters
27
- # @return [true] If `alg` is absent or matches this key
28
- # @raise [VerifyError] If `alg` selects another ML-DSA parameter set
29
- def validate_signature_parameters(parameters)
30
- supplied_algorithm = parameters["alg"] || parameters[:alg]
31
- return true if supplied_algorithm.nil? || supplied_algorithm == algorithm
32
-
33
- raise VerifyError,
34
- "Signature algorithm #{supplied_algorithm} does not match key algorithm #{algorithm}"
35
- end
36
-
37
- # Signs an RFC 9421 signature base with an empty FIPS 204 context.
38
- #
39
- # @param data [String] Signature base bytes
40
- # @return [String] Raw FIPS 204 signature bytes
41
- # @raise [SigningError] If private key material is unavailable
42
- def sign(data)
43
- validate_signing_key
44
- material.sign(data, context: "")
45
- rescue MlDsa::Error => e
46
- raise SigningError, e.message, cause: e
47
- end
48
-
49
- # Verifies an RFC 9421 signature base with an empty FIPS 204 context.
50
- #
51
- # @param signature [String] Raw FIPS 204 signature bytes
52
- # @param data [String] Signature base bytes
53
- # @return [Boolean] Whether the signature is valid
54
- # @raise [VerifyError] If public key material is unavailable
55
- def verify(signature, data)
56
- validate_verify_key
57
- return false unless signature.is_a?(String)
58
- return false unless signature.bytesize == parameter_set.signature_bytes
59
-
60
- verification_material.verify(data, signature, context: "")
61
- rescue MlDsa::Error, ArgumentError, TypeError
62
- false
63
- end
64
-
65
- private
66
-
67
- def validate
68
- super
69
- expected = ALGORITHMS[algorithm]
70
- raise Error, "Unsupported ML-DSA algorithm: #{algorithm}" unless expected
71
-
72
- valid_type = material.is_a?(MlDsa::PublicKey) || material.is_a?(MlDsa::SecretKey)
73
- raise Error, "Invalid ML-DSA key material" unless valid_type
74
-
75
- return if material.param_set == expected
76
-
77
- raise Error,
78
- "ML-DSA key parameter set #{material.param_set} does not match #{algorithm}"
79
- end
80
-
81
- def parameter_set
82
- ALGORITHMS.fetch(algorithm)
83
- end
84
-
85
- def verification_material
86
- return material if material.is_a?(MlDsa::PublicKey)
87
- return material.public_key if material.public_key
88
-
89
- raise VerifyError, "Public key is needed!"
90
- end
91
-
92
- def compute_private?
93
- material.is_a?(MlDsa::SecretKey)
94
- end
95
-
96
- def compute_public?
97
- material.is_a?(MlDsa::PublicKey) ||
98
- (material.is_a?(MlDsa::SecretKey) && !material.public_key.nil?)
99
- end
100
- end
101
24
  end
102
25
  end
@@ -139,7 +139,7 @@ module Linzer
139
139
  #
140
140
  params[:expires] ||= Time.now.to_i + 3600
141
141
  params[:tag] ||= "web-bot-auth"
142
- params[:keyid] ||= key.material.key_digest
142
+ params[:keyid] ||= key.jwk_thumbprint
143
143
  end
144
144
 
145
145
  # Injects and signs the Signature-Agent header.
@@ -3,5 +3,5 @@
3
3
  module Linzer
4
4
  # Current version of the Linzer gem.
5
5
  # @return [String]
6
- VERSION = "0.8.1.beta1"
6
+ VERSION = "0.8.1.beta2"
7
7
  end
data/lib/linzer.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "starry"
4
4
  require "openssl"
5
- require "ml_dsa"
6
5
  require "uri"
7
6
  require "net/http"
8
7
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: linzer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1.beta1
4
+ version: 0.8.1.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Landaeta
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.2'
26
- - !ruby/object:Gem::Dependency
27
- name: ml_dsa
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '0.1'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '0.1'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: uri
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -177,6 +163,8 @@ files:
177
163
  - lib/linzer/message/overlay.rb
178
164
  - lib/linzer/message/wrapper.rb
179
165
  - lib/linzer/ml_dsa.rb
166
+ - lib/linzer/ml_dsa/gem_key.rb
167
+ - lib/linzer/ml_dsa/openssl_key.rb
180
168
  - lib/linzer/options.rb
181
169
  - lib/linzer/rack.rb
182
170
  - lib/linzer/rsa.rb