linzer 0.8.0 → 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: 47932906cedb5dd66d7d2bbc5e8eaac9f01b4e17dd7089bc87b4a06b9505b366
4
- data.tar.gz: abe9bf21ac6d66a08f229de3fb10e77bc911c112f812cd0cf620d9f3947d580e
3
+ metadata.gz: 1bc1cb6232547c649f0c082b444d858632abcb7cd923a50c1bf55ea6da316034
4
+ data.tar.gz: 97da42478725e85ab12f9475f2808350dc76981c3f705a8e1479c57b7509f095
5
5
  SHA512:
6
- metadata.gz: b4352492dedad2bd9881b9bc6ac5888cdf7855a798f2b650040a9a64c4f7ea81c34178a4bfadb77f5953d82498f1eea69c90529d8b3888d9d182d3896e8415ed
7
- data.tar.gz: b2540b62b29a07666c66969f5d6843f674994747fe80e47e71c0ba7f03ad554ccfbd1fcfefca796b7115f6fa2643f2b57b4d058de755c499a87ac87f883a4282
6
+ metadata.gz: 537e717e95afee1f2c32534ef294608020869b59aa4b07abdc58a629df11c74878390d0f0fa6202bbb7adf913ee70defde827f6f4c8e3c5d50d60c5fd159a1fe
7
+ data.tar.gz: 55337d62a400418a8b46bbd240a588e0fa6d66aef18df4c10938b0715919f8b236fb985a3ab0a082cb7fc10f13b27051f261daecf48a469b33ff11c5d7a19b7f
data/.standard.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # For available configuration options, see:
2
2
  # https://github.com/testdouble/standard
3
- ruby_version: 2.6
3
+ ruby_version: 2.7
4
4
 
5
5
  ignore:
6
6
  - '**/*':
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
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
+
20
+ ## [0.8.1.beta1] - 2026-08-08
21
+
22
+ - Add ML-DSA-44, ML-DSA-65, and ML-DSA-87 support following the C2SP
23
+ post-quantum HTTP Message Signatures profile, including raw, DER, and PEM
24
+ key loading and strict parameter-set binding.
25
+ Pull request [#29](https://github.com/nomadium/linzer/pull/29)
26
+ by [soatok](https://github.com/soatok).
27
+
28
+ - Due to the `ml_dsa` gemspec, the minimum Ruby version was bumped to 2.7.2.
29
+
3
30
  ## [0.8.0] - 2026-08-08
4
31
 
5
32
  (Only one change since the last beta: the Faraday bug fix below. Everything
@@ -37,6 +64,9 @@ else shipped in earlier 0.8.0 betas.)
37
64
  - A `params_encoder` configured on the connection or request is now
38
65
  preserved, instead of being replaced with the default encoder.
39
66
 
67
+ Pull request [#31](https://github.com/nomadium/linzer/pull/31)
68
+ by [Aupajo](https://github.com/Aupajo).
69
+
40
70
  ## [0.8.0.beta2] - 2026-05-20
41
71
 
42
72
  - Add Web Bot Auth support, implementing the current IETF draft
data/README.md CHANGED
@@ -554,6 +554,46 @@ Linzer currently supports the following signature algorithms:
554
554
  - HMAC-SHA256
555
555
  - Ed25519
556
556
  - ECDSA (P-256 and P-384 curves).
557
+ - ML-DSA-44, ML-DSA-65, and ML-DSA-87 ([C2SP profile](https://c2sp.org/httpsig-pq))
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
557
597
 
558
598
  Of the JSON Web Signature (JWS) algorithms mentioned in RFC 9421,
559
599
  only Ed25519 is currently supported. Support for additional
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
@@ -241,6 +241,74 @@ module Linzer
241
241
  Linzer::ECDSA::Key.new(key, id: key_id, digest: "SHA384")
242
242
  end
243
243
 
244
+ # Generates an ML-DSA-44 key pair.
245
+ #
246
+ # @param key_id [String, nil] Optional key identifier
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
252
+ # @see https://c2sp.org/httpsig-pq C2SP post-quantum HTTP signatures
253
+ def generate_ml_dsa_44_key(key_id = nil, backend: :auto)
254
+ generate_ml_dsa_key("ml-dsa-44", key_id, backend)
255
+ end
256
+
257
+ # Generates an ML-DSA-65 key pair.
258
+ #
259
+ # @param key_id [String, nil] Optional key identifier
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)
266
+ end
267
+
268
+ # Generates an ML-DSA-87 key pair.
269
+ #
270
+ # @param key_id [String, nil] Optional key identifier
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)
277
+ end
278
+
279
+ # Loads a raw, DER, or PEM ML-DSA-44 private or public key.
280
+ #
281
+ # @param material [String, MlDsa::SecretKey, MlDsa::PublicKey] Key material
282
+ # @param key_id [String, nil] Optional key identifier
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)
288
+ end
289
+
290
+ # Loads a raw, DER, or PEM ML-DSA-65 private or public key.
291
+ #
292
+ # @param material [String, MlDsa::SecretKey, MlDsa::PublicKey] Key material
293
+ # @param key_id [String, nil] Optional key identifier
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)
299
+ end
300
+
301
+ # Loads a raw, DER, or PEM ML-DSA-87 private or public key.
302
+ #
303
+ # @param material [String, MlDsa::SecretKey, MlDsa::PublicKey] Key material
304
+ # @param key_id [String, nil] Optional key identifier
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)
310
+ end
311
+
244
312
  # Generates a new JWS key for the specified algorithm.
245
313
  #
246
314
  # This method generates keys compatible with JSON Web Signature (JWS)
@@ -269,6 +337,113 @@ module Linzer
269
337
  def jwk_import(key, params = {})
270
338
  Linzer::JWS.jwk_import(key, params)
271
339
  end
340
+
341
+ private
342
+
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)
369
+ parameter_set = Linzer::MLDSA::ALGORITHMS.fetch(algorithm)
370
+ pair = MlDsa.keygen(parameter_set)
371
+ Linzer::MLDSA::GemKey.new(pair.secret_key, id: key_id, algorithm: algorithm)
372
+ rescue MlDsa::Error, ArgumentError, TypeError => e
373
+ raise Linzer::Error, e.message, cause: e
374
+ end
375
+
376
+ # NOTE: see generate_ml_dsa_key_via_gem, same reason.
377
+ def new_ml_dsa_key_via_gem(material, algorithm, key_id)
378
+ parameter_set = Linzer::MLDSA::ALGORITHMS.fetch(algorithm)
379
+ key = deserialize_ml_dsa_key(material, parameter_set)
380
+ Linzer::MLDSA::GemKey.new(key, id: key_id, algorithm: algorithm)
381
+ rescue MlDsa::Error, ArgumentError, TypeError => e
382
+ raise Linzer::Error, e.message, cause: e
383
+ end
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
+
425
+ def deserialize_ml_dsa_key(material, parameter_set)
426
+ return material if material.is_a?(MlDsa::PublicKey) || material.is_a?(MlDsa::SecretKey)
427
+ unless material.is_a?(String)
428
+ raise TypeError, "ML-DSA key material must be a String, MlDsa::PublicKey, or MlDsa::SecretKey"
429
+ end
430
+
431
+ if material.bytesize == parameter_set.public_key_bytes
432
+ MlDsa::PublicKey.from_bytes(material, parameter_set)
433
+ elsif material.bytesize == parameter_set.secret_key_bytes
434
+ MlDsa::SecretKey.from_bytes(material, parameter_set)
435
+ elsif material.start_with?("-----BEGIN PUBLIC KEY-----")
436
+ MlDsa::PublicKey.from_pem(material)
437
+ elsif material.start_with?("-----BEGIN PRIVATE KEY-----")
438
+ MlDsa::SecretKey.from_pem(material)
439
+ else
440
+ begin
441
+ MlDsa::SecretKey.from_der(material)
442
+ rescue MlDsa::Error::Deserialization
443
+ MlDsa::PublicKey.from_der(material)
444
+ end
445
+ end
446
+ end
272
447
  end
273
448
  end
274
449
  end
@@ -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
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ml_dsa/openssl_key"
4
+
5
+ module Linzer
6
+ # ML-DSA support for HTTP Message Signatures as specified by https://c2sp.org/httpsig-pq
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
+ #
21
+ # @see https://c2sp.org/httpsig-pq C2SP post-quantum HTTP signatures
22
+ # @see https://csrc.nist.gov/pubs/fips/204/final FIPS 204
23
+ module MLDSA
24
+ end
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.
@@ -85,6 +85,10 @@ module Linzer
85
85
  raise VerifyError, "Signature raw value to cannot be null" if signature.value.nil?
86
86
  raise VerifyError, "Components cannot be null" if signature.serialized_components.nil?
87
87
 
88
+ if key.respond_to?(:validate_signature_parameters)
89
+ key.validate_signature_parameters(signature.parameters)
90
+ end
91
+
88
92
  begin
89
93
  validate_components message, signature.serialized_components,
90
94
  field_ids: signature.field_ids
@@ -3,5 +3,5 @@
3
3
  module Linzer
4
4
  # Current version of the Linzer gem.
5
5
  # @return [String]
6
- VERSION = "0.8.0"
6
+ VERSION = "0.8.1.beta2"
7
7
  end
data/lib/linzer.rb CHANGED
@@ -26,6 +26,7 @@ require_relative "linzer/rsa_pss"
26
26
  require_relative "linzer/hmac"
27
27
  require_relative "linzer/ed25519"
28
28
  require_relative "linzer/ecdsa"
29
+ require_relative "linzer/ml_dsa"
29
30
  require_relative "linzer/key/helper"
30
31
  require_relative "linzer/signer"
31
32
  require_relative "linzer/verifier"
@@ -33,7 +34,7 @@ require_relative "linzer/verifier"
33
34
  # Linzer is a Ruby library for HTTP Message Signatures as defined in RFC 9421.
34
35
  #
35
36
  # It provides functionality to sign and verify HTTP messages using various
36
- # cryptographic algorithms including RSA-PSS, HMAC-SHA256, ECDSA, and Ed25519.
37
+ # cryptographic algorithms including RSA-PSS, HMAC-SHA256, ECDSA, Ed25519, and ML-DSA.
37
38
  #
38
39
  # @example Signing a request with Ed25519
39
40
  # key = Linzer.generate_ed25519_key("my-key-id")
@@ -193,7 +193,10 @@ module Rack
193
193
  "hmac-sha256" => :new_hmac_sha256_key,
194
194
  "ecdsa-p256-sha256" => :new_ecdsa_p256_sha256_key,
195
195
  "ecdsa-p384-sha384" => :new_ecdsa_p384_sha384_key,
196
- "ed25519" => :new_ed25519_public_key
196
+ "ed25519" => :new_ed25519_public_key,
197
+ "ml-dsa-44" => :new_ml_dsa_44_key,
198
+ "ml-dsa-65" => :new_ml_dsa_65_key,
199
+ "ml-dsa-87" => :new_ml_dsa_87_key
197
200
  }
198
201
  method = key_methods[alg]
199
202
 
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.0
4
+ version: 0.8.1.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Landaeta
@@ -162,6 +162,9 @@ files:
162
162
  - lib/linzer/message/field/parser.rb
163
163
  - lib/linzer/message/overlay.rb
164
164
  - lib/linzer/message/wrapper.rb
165
+ - lib/linzer/ml_dsa.rb
166
+ - lib/linzer/ml_dsa/gem_key.rb
167
+ - lib/linzer/ml_dsa/openssl_key.rb
165
168
  - lib/linzer/options.rb
166
169
  - lib/linzer/rack.rb
167
170
  - lib/linzer/rsa.rb
@@ -191,7 +194,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
191
194
  requirements:
192
195
  - - ">="
193
196
  - !ruby/object:Gem::Version
194
- version: 2.6.0
197
+ version: 2.7.2
195
198
  required_rubygems_version: !ruby/object:Gem::Requirement
196
199
  requirements:
197
200
  - - ">="