linzer 0.8.1.beta1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ced6e7bed237dbb28cefa79ed9289fa19db7510a7078c64315f92d50ae637b7d
4
- data.tar.gz: 291d1e92359fba0801ad5dfa543e6eb132c43c1f94686be5749462fcf3e18f2e
3
+ metadata.gz: 188e4a68a86287824bb5ba37a0c1b476abb9bd20b7b96048f13f230c070bab08
4
+ data.tar.gz: dc3e0081f6baaaf85d30e532fe7c5cf0bf1cb1e7b566dd8493701b754fabdfa3
5
5
  SHA512:
6
- metadata.gz: 968bac61643099b34b73696193f8b5af9ab4489393b7d3d67d4f3fe70c35509c61e48acd659d4e78f101a8fabd7be65b549e87e6376bbaa146ff6cb755b894a8
7
- data.tar.gz: d901254dd56d886b96b8e4c6425958e63b97841f2682958cb1fd2b020b2799f2a81c15609f74aa8790f80692756d2647f9359f45729ac15f2ca1eaf1e20b0699
6
+ metadata.gz: 2af5b16fa7832bce168d2fa024dbbe28adfff00d126b520c87e241348eb34f9f1133fd98a85ca3fec6cfd945f773165e0cfe3230420e086a91f1f59fa4eb67b8
7
+ data.tar.gz: 386f5fb8bfe0ddea3d045c66ed52d52a9dab9704c79662ada309027185b67cf0cd6887e5c4becaa64a8410c1aa4419c2274c6739d29743d74d9854da79f3e4c8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1] - 2026-08-25
4
+
5
+ (No major changes since the last beta release, this stable release
6
+ bundles the ML-DSA/post-quantum work from both 0.8.1 betas
7
+ (`ml_dsa`-gem-backed in beta1, OpenSSL-native and default-preferred in
8
+ beta2), completing post-quantum HTTP Message Signatures support and
9
+ closing [#28](https://github.com/nomadium/linzer/issues/28), plus a few
10
+ unrelated Web Bot Auth fixes.)
11
+
12
+ - Add ML-DSA-44, ML-DSA-65, and ML-DSA-87 support following the C2SP
13
+ post-quantum HTTP Message Signatures profile, including raw, DER, and PEM
14
+ key loading and strict parameter-set binding.
15
+ Pull request [#29](https://github.com/nomadium/linzer/pull/29)
16
+ by [soatok](https://github.com/soatok).
17
+ It can be used explicitly via `backend: :ml_dsa` by adding the `ml_dsa`
18
+ gem to your own Gemfile and `require "linzer/ml_dsa"` before use.
19
+
20
+ - Add an OpenSSL-native ML-DSA backend, preferred by default over the
21
+ `ml_dsa` gem whenever this build's OpenSSL actually supports it (all
22
+ three FIPS 204 parameter sets: ML-DSA-44, ML-DSA-65, ML-DSA-87). Pass
23
+ `backend: :openssl` or `backend: :ml_dsa` to any
24
+ `generate_ml_dsa_*_key`/`new_ml_dsa_*_key` method to select
25
+ explicitly instead of relying on auto-selection; `key.backend`
26
+ reports which one produced a given key.
27
+ Pull request [#33](https://github.com/nomadium/linzer/pull/33)
28
+ by [nomadium](https://github.com/nomadium).
29
+ Since OpenSSL is now the default backend for ML-DSA, `ml_dsa` is no
30
+ longer a hard runtime dependency, so the minimum Ruby version moves
31
+ back down to 2.7.0.
32
+
33
+ - ML-DSA keys generated or loaded with one backend can now be moved to
34
+ the other: raw public/private key bytes round-trip correctly between
35
+ the OpenSSL-native and `ml_dsa` gem backends. The raw-byte extraction
36
+ methods behind this (`Linzer::MLDSA::OpenSSLKey.unwrap_raw_public_key`/
37
+ `unwrap_raw_private_key`) are available for advanced use, though not
38
+ yet a stable public API.
39
+
40
+ - Fix Web Bot Auth's `keyid` being computed incorrectly for
41
+ Ed25519/OKP keys: jwt-eddsa <= 0.9.0 computed the RFC 7638 JWK
42
+ thumbprint over the wrong members, so the derived `keyid` didn't
43
+ match what a spec-compliant verifier expects. Linzer now computes it
44
+ independently via the new `Linzer::JWS::Key#jwk_thumbprint`.
45
+
46
+ - `Linzer::JWS::Key#jwk_thumbprint` now delegates to jwt-eddsa's own
47
+ `key_digest` instead of a local RFC 7638 workaround, now that
48
+ jwt-eddsa >= 1.0 computes it correctly for OKP keys. Linzer now
49
+ requires jwt-eddsa >= 1.0 for JWS/EdDSA support and raises a clear
50
+ error at `require "linzer/jws"` time on older versions.
51
+
52
+ - Fix `Signer.sign!`'s signature `label` defaulting to
53
+ `"sig1"` too late — after `profile.apply(ctx)` already ran, breaking
54
+ any profile (like Web Bot Auth's `Signature-Agent` header) that needs
55
+ a real label at apply time. The default now applies before any
56
+ profile runs.
57
+
58
+ - Fix `Signature-Agent` being sent as a bare, unquoted URL,
59
+ which isn't valid per draft-meunier-web-bot-auth-architecture
60
+ Appendix A.1.3. Now quoted as a proper RFC 8941 Structured Field
61
+ string.
62
+
63
+ ## [0.8.1.beta2] - 2026-08-14
64
+
65
+ - Add an OpenSSL-native ML-DSA backend, preferred by default over the
66
+ `ml_dsa` gem whenever this build's OpenSSL actually supports it (all
67
+ three FIPS 204 parameter sets: ML-DSA-44, ML-DSA-65, ML-DSA-87). Pass
68
+ `backend: :openssl` or `backend: :ml_dsa` to any
69
+ `generate_ml_dsa_*_key`/`new_ml_dsa_*_key` method to select
70
+ explicitly instead of relying on auto-selection; `key.backend`
71
+ reports which one produced a given key.
72
+ Pull request [#33](https://github.com/nomadium/linzer/pull/33).
73
+
74
+ - The `ml_dsa` gem is no longer a hard runtime dependency, it's an
75
+ optional backend now, a real behavior change from beta1. To keep
76
+ using it (e.g. on older OpenSSL builds, or explicitly via
77
+ `backend: :ml_dsa`), add it to your own Gemfile and
78
+ `require "linzer/ml_dsa/gem_key"` before use.
79
+
3
80
  ## [0.8.1.beta1] - 2026-08-08
4
81
 
5
82
  - Add ML-DSA-44, ML-DSA-65, and ML-DSA-87 support following the C2SP
data/README.md CHANGED
@@ -556,6 +556,53 @@ 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"
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
+ Keys are portable between the two backends: raw public/private key
597
+ bytes produced by one load correctly via the other, so switching
598
+ `backend:` -- or moving a key between hosts with different OpenSSL
599
+ capabilities -- doesn't require regenerating anything. (The raw-byte
600
+ extraction methods behind this, `Linzer::MLDSA::OpenSSLKey.unwrap_raw_public_key`/
601
+ `unwrap_raw_private_key`, are available for advanced use but not yet a
602
+ stable public API.)
603
+
604
+ ### JSON Web Signature (JWS) algorithms
605
+
559
606
  Of the JSON Web Signature (JWS) algorithms mentioned in RFC 9421,
560
607
  only Ed25519 is currently supported. Support for additional
561
608
  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
@@ -4,6 +4,18 @@ require "jwt"
4
4
  require "jwt/eddsa"
5
5
  require "ed25519"
6
6
 
7
+ # jwt-eddsa isn't a runtime dependency of this gemspec, callers who want
8
+ # JWS/EdDSA support add it to their own Gemfile. jwt-eddsa <= 0.9.0 has a
9
+ # bug where its OKP JWK class computes thumbprints over the wrong members
10
+ # (see Linzer::JWS::Key#jwk_thumbprint, which delegates to it), so guard
11
+ # against a too-old version actually being resolved instead of silently
12
+ # producing wrong keyids.
13
+ if Gem::Version.new(JWT::EdDSA::VERSION) < Gem::Version.new("1.0")
14
+ raise Linzer::Error,
15
+ "linzer requires jwt-eddsa >= 1.0 (found #{JWT::EdDSA::VERSION}); " \
16
+ "versions <= 0.9.0 compute OKP JWK thumbprints incorrectly"
17
+ end
18
+
7
19
  module Linzer
8
20
  # JSON Web Signature (JWS) compatible key support.
9
21
  #
@@ -101,6 +113,24 @@ module Linzer
101
113
  algo.verify(data: data, signature: signature, verification_key: verify_key)
102
114
  end
103
115
 
116
+ # Computes the RFC 7638 JWK SHA-256 Thumbprint for this key's public
117
+ # material.
118
+ #
119
+ # @return [String] base64url-encoded (no padding) SHA-256 thumbprint
120
+ # @raise [Error] if this key's JWK "kty" is not supported
121
+ #
122
+ # @see https://www.rfc-editor.org/rfc/rfc7638 RFC 7638 - JSON Web Key (JWK) Thumbprint
123
+ # @see https://www.rfc-editor.org/rfc/rfc8037 RFC 8037 - EdDSA for JWS/JWK
124
+ def jwk_thumbprint
125
+ exported = material.export
126
+
127
+ if exported[:kty] != "OKP"
128
+ raise Error, "Unsupported JWK kty for thumbprint: #{exported[:kty]}"
129
+ end
130
+
131
+ material.key_digest
132
+ end
133
+
104
134
  private
105
135
 
106
136
  # @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,94 @@ 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
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error => e
353
+ raise Linzer::Error, e.message, cause: e
354
+ end
355
+
356
+ def new_ml_dsa_key(material, algorithm, key_id, backend)
357
+ case resolve_ml_dsa_backend(algorithm, backend)
358
+ when :openssl
359
+ key = Linzer::MLDSA::OpenSSLKey.deserialize_raw_or_encoded_key(material, algorithm)
360
+ Linzer::MLDSA::OpenSSLKey.new(key, id: key_id, algorithm: algorithm)
361
+ when :ml_dsa
362
+ ensure_ml_dsa_gem_key_available!
363
+ new_ml_dsa_key_via_gem(material, algorithm, key_id)
364
+ end
365
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error, TypeError => e
366
+ raise Linzer::Error, e.message, cause: e
367
+ end
368
+
369
+ # NOTE: ensure_ml_dsa_gem_key_available! must run in the caller, not
370
+ # here. This method's own `rescue MlDsa::Error` would need MlDsa
371
+ # to be defined just to evaluate, defeating the guard.
372
+ def generate_ml_dsa_key_via_gem(algorithm, key_id)
328
373
  parameter_set = Linzer::MLDSA::ALGORITHMS.fetch(algorithm)
329
374
  pair = MlDsa.keygen(parameter_set)
330
- Linzer::MLDSA::Key.new(pair.secret_key, id: key_id, algorithm: algorithm)
375
+ Linzer::MLDSA::GemKey.new(pair.secret_key, id: key_id, algorithm: algorithm)
331
376
  rescue MlDsa::Error, ArgumentError, TypeError => e
332
377
  raise Linzer::Error, e.message, cause: e
333
378
  end
334
379
 
335
- def new_ml_dsa_key(material, algorithm, key_id)
380
+ # NOTE: see generate_ml_dsa_key_via_gem, same reason.
381
+ def new_ml_dsa_key_via_gem(material, algorithm, key_id)
336
382
  parameter_set = Linzer::MLDSA::ALGORITHMS.fetch(algorithm)
337
383
  key = deserialize_ml_dsa_key(material, parameter_set)
338
- Linzer::MLDSA::Key.new(key, id: key_id, algorithm: algorithm)
384
+ Linzer::MLDSA::GemKey.new(key, id: key_id, algorithm: algorithm)
339
385
  rescue MlDsa::Error, ArgumentError, TypeError => e
340
386
  raise Linzer::Error, e.message, cause: e
341
387
  end
342
388
 
389
+ # Resolves a `backend:` argument (:auto, :openssl, or :ml_dsa) to the
390
+ # concrete backend to actually use for a given algorithm.
391
+ #
392
+ # :auto trusts Linzer::MLDSA.openssl_supported?(algorithm) completely,
393
+ # the single source of truth for whether OpenSSL both can and has
394
+ # been wired up to handle this algorithm. An explicit :openssl request
395
+ # is held to the same standard: it's rejected up front, with a clear
396
+ # error, rather than silently attempted for an algorithm OpenSSLKey
397
+ # doesn't support (e.g. an unimplemented future parameter set, or a
398
+ # build without ML-DSA enabled).
399
+ #
400
+ # @return [Symbol] :openssl or :ml_dsa
401
+ # @raise [Error] If backend is :openssl but unavailable for algorithm,
402
+ # or if backend is anything other than :auto/:openssl/:ml_dsa
403
+ def resolve_ml_dsa_backend(algorithm, backend)
404
+ case backend
405
+ when :auto
406
+ Linzer::MLDSA.openssl_supported?(algorithm) ? :openssl : :ml_dsa
407
+ when :openssl
408
+ unless Linzer::MLDSA.openssl_supported?(algorithm)
409
+ raise Linzer::Error, "OpenSSL-backed ML-DSA is not available for #{algorithm} on this build"
410
+ end
411
+ :openssl
412
+ when :ml_dsa
413
+ :ml_dsa
414
+ else
415
+ raise Linzer::Error, "Unknown ML-DSA backend: #{backend.inspect} (expected :auto, :openssl, or :ml_dsa)"
416
+ end
417
+ end
418
+
419
+ # @raise [Error] If Linzer::MLDSA::GemKey isn't loaded (the ml_dsa
420
+ # gem backend is opt-in via `require "linzer/ml_dsa"`, see
421
+ # lib/linzer/ml_dsa.rb)
422
+ def ensure_ml_dsa_gem_key_available!
423
+ return if defined?(Linzer::MLDSA::GemKey)
424
+
425
+ raise Linzer::Error,
426
+ "ML-DSA gem backend not available: " \
427
+ 'require "linzer/ml_dsa" first (needs the ml_dsa gem installed; ' \
428
+ 'plain require "linzer" only gets you the OpenSSL backend)'
429
+ end
430
+
343
431
  def deserialize_ml_dsa_key(material, parameter_set)
344
432
  return material if material.is_a?(MlDsa::PublicKey) || material.is_a?(MlDsa::SecretKey)
345
433
  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"` 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,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ module MLDSA
5
+ # Linzer algorithm identifiers (lowercase, e.g. "ml-dsa-44") that this
6
+ # OpenSSL-backed implementation actually has construction/OID support
7
+ # for today. {openssl_supported?} treats anything outside this set as
8
+ # unsupported without ever asking OpenSSL about it.
9
+ # @return [Array<String>]
10
+ IMPLEMENTED_ALGORITHMS = %w[ml-dsa-44 ml-dsa-65 ml-dsa-87].freeze
11
+ private_constant :IMPLEMENTED_ALGORITHMS
12
+
13
+ # ML-DSA (FIPS 204) signing/verification backed directly by OpenSSL
14
+ # 3.5+, with no additional gem dependency. Supports all three
15
+ # parameter sets (ML-DSA-44/65/87).
16
+ #
17
+ # Like Ed25519, ML-DSA is a "pure"/digest-less signature scheme: the
18
+ # RFC 9421 signature base is signed directly, with no prehashing.
19
+ #
20
+ # @note Requires OpenSSL 3.5+ with ML-DSA signature algorithms enabled.
21
+ # Some distributions ship OpenSSL 3.5+ with these disabled by crypto
22
+ # policy (see https://github.com/ruby/openssl/issues/1075), so callers
23
+ # should be prepared for {OpenSSL::PKey::PKeyError} on unsupported
24
+ # builds even when the OpenSSL version alone looks sufficient.
25
+ #
26
+ # @see Linzer::MLDSA::GemKey for the `ml_dsa`-gem-backed alternative
27
+ # @see https://github.com/C2SP/C2SP/blob/httpsig-pq/v0.2.0/httpsig-pq.md
28
+ # C2SP httpsig-pq: Post-Quantum Algorithms for HTTP Message Signatures
29
+ # @see https://csrc.nist.gov/pubs/fips/204/final FIPS 204
30
+ class OpenSSLKey < Linzer::Key
31
+ # NIST OIDs for ML-DSA (id-ml-dsa-44/65/87), used both by OpenSSL's own
32
+ # PEM/DER encoding and by {wrap_raw_public_key}/{wrap_raw_private_key}
33
+ # below when reconstructing a key from raw FIPS 204 bytes. Verified
34
+ # against real OpenSSL 3.5+ output (decoded from a freshly generated
35
+ # key's own `public_to_der`), not taken from documentation alone.
36
+ # @return [Hash{String => String}]
37
+ OPENSSL_OIDS = {
38
+ "ml-dsa-44" => "2.16.840.1.101.3.4.3.17",
39
+ "ml-dsa-65" => "2.16.840.1.101.3.4.3.18",
40
+ "ml-dsa-87" => "2.16.840.1.101.3.4.3.19"
41
+ }.freeze
42
+ private_constant :OPENSSL_OIDS
43
+
44
+ # FIPS 204 raw public-key sizes per parameter set, used to sniff raw
45
+ # key material in {deserialize_raw_or_encoded_key} the same way
46
+ # {GemKey} does for the gem backend.
47
+ # @return [Hash{String => Integer}]
48
+ RAW_PUBLIC_KEY_BYTES = {
49
+ "ml-dsa-44" => 1312,
50
+ "ml-dsa-65" => 1952,
51
+ "ml-dsa-87" => 2592
52
+ }.freeze
53
+ private_constant :RAW_PUBLIC_KEY_BYTES
54
+
55
+ # FIPS 204 raw (expanded, seed-free) private-key sizes per parameter set.
56
+ # @return [Hash{String => Integer}]
57
+ RAW_PRIVATE_KEY_BYTES = {
58
+ "ml-dsa-44" => 2560,
59
+ "ml-dsa-65" => 4032,
60
+ "ml-dsa-87" => 4896
61
+ }.freeze
62
+ private_constant :RAW_PRIVATE_KEY_BYTES
63
+
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
96
+ # material, or the underlying OpenSSL signing operation fails
97
+ def sign(data)
98
+ validate_signing_key
99
+ material.sign(nil, data)
100
+ rescue OpenSSL::PKey::PKeyError => e
101
+ raise SigningError, e.message, cause: e
102
+ end
103
+
104
+ # Verifies a signature using the ML-DSA public key.
105
+ #
106
+ # @param signature [String] The signature bytes to verify
107
+ # @param data [String] The data that was signed
108
+ # @return [Boolean] true if the signature is valid, false otherwise
109
+ # (including malformed or non-String `signature`/`data` input,
110
+ # matching {Linzer::MLDSA::GemKey#verify}'s contract)
111
+ # @raise [VerifyError] If this key does not contain public key material
112
+ def verify(signature, data)
113
+ validate_verify_key
114
+ return false unless signature.is_a?(String)
115
+
116
+ material.verify(nil, signature, data)
117
+ rescue OpenSSL::PKey::PKeyError, ArgumentError, TypeError
118
+ false
119
+ end
120
+
121
+ # @return [Symbol] :openssl -- which backend produced this key
122
+ def backend
123
+ :openssl
124
+ end
125
+
126
+ class << self
127
+ # Builds an OpenSSL key from ML-DSA material of unknown shape: raw
128
+ # FIPS 204 bytes for `algorithm` (sniffed by exact byte length, the
129
+ # same approach {Linzer::MLDSA::GemKey} uses for the gem backend) or
130
+ # an OpenSSL-encoded PEM/DER key, handled as a fallback. Sniffing is
131
+ # scoped to `algorithm`'s own raw sizes, not all three parameter
132
+ # sets' sizes at once, material sized for a *different* parameter
133
+ # set than requested falls through to the `OpenSSL::PKey.read`
134
+ # fallback and fails there (raw bytes aren't valid PEM/DER), rather
135
+ # than silently being accepted and mislabeled.
136
+ #
137
+ # @param material [String] Raw FIPS 204 bytes, or a PEM/DER-encoded key
138
+ # @param algorithm [String] Linzer's lowercase algorithm identifier,
139
+ # e.g. `"ml-dsa-44"`
140
+ # @return [OpenSSL::PKey::PKey]
141
+ # @raise [TypeError] If `material` isn't a String
142
+ # @api private
143
+ def deserialize_raw_or_encoded_key(material, algorithm)
144
+ raise TypeError, "ML-DSA key material must be a String" unless material.is_a?(String)
145
+
146
+ case material.bytesize
147
+ when RAW_PUBLIC_KEY_BYTES.fetch(algorithm)
148
+ wrap_raw_public_key(material, algorithm)
149
+ when RAW_PRIVATE_KEY_BYTES.fetch(algorithm)
150
+ wrap_raw_private_key(material, algorithm)
151
+ else
152
+ OpenSSL::PKey.read(material)
153
+ end
154
+ end
155
+
156
+ # Extracts the raw FIPS 204 public key bytes from an OpenSSL key's
157
+ # own SubjectPublicKeyInfo encoding -- the inverse of
158
+ # {wrap_raw_public_key}. Used to hand a key generated (or loaded)
159
+ # via this backend to the `ml_dsa` gem backend instead.
160
+ #
161
+ # @param key [OpenSSL::PKey::PKey] A public or private ML-DSA key
162
+ # @return [String] Raw FIPS 204 public key bytes
163
+ # @api private
164
+ def unwrap_raw_public_key(key)
165
+ spki = OpenSSL::ASN1.decode(key.public_to_der)
166
+ spki.value[1].value
167
+ end
168
+
169
+ # Extracts the raw (expanded, seed-free) FIPS 204 private key bytes
170
+ # from an OpenSSL key's own PKCS8 encoding -- the inverse of
171
+ # {wrap_raw_private_key}. Used to hand a key generated via this
172
+ # backend to the `ml_dsa` gem backend instead.
173
+ #
174
+ # OpenSSL's own generated keys always use the "both" alternative of
175
+ # the ML-DSA private key CHOICE (a seed alongside the expanded key,
176
+ # confirmed empirically -- see the strategy notes), so this only
177
+ # handles that shape; anything else raises rather than silently
178
+ # returning the wrong bytes.
179
+ #
180
+ # @param key [OpenSSL::PKey::PKey] A private ML-DSA key
181
+ # @return [String] Raw FIPS 204 expanded private key bytes
182
+ # @raise [Error] If the key's private key CHOICE isn't the expected
183
+ # seed+expandedKey SEQUENCE
184
+ # @api private
185
+ def unwrap_raw_private_key(key)
186
+ one_asymmetric_key = OpenSSL::ASN1.decode(key.private_to_der)
187
+ private_key_choice = OpenSSL::ASN1.decode(one_asymmetric_key.value[2].value)
188
+ unless private_key_choice.is_a?(OpenSSL::ASN1::Sequence)
189
+ raise Error, "Unsupported ML-DSA private key encoding (expected seed+expandedKey)"
190
+ end
191
+
192
+ private_key_choice.value[1].value
193
+ end
194
+
195
+ private
196
+
197
+ # Reconstructs an OpenSSL key from a raw FIPS 204 ML-DSA public key.
198
+ #
199
+ # The `openssl` gem does not yet accept ML-DSA algorithm names in
200
+ # {OpenSSL::PKey.new_raw_public_key} (there is no upstream issue
201
+ # tracking this as of this writing), so this wraps the raw bytes in a
202
+ # minimal DER SubjectPublicKeyInfo structure that {OpenSSL::PKey.read}
203
+ # does accept. Verified byte-identical to OpenSSL's own
204
+ # `public_to_der` output for ml-dsa-44; for ml-dsa-65/87, verified
205
+ # via a full raw-bytes-in/sign/verify round trip instead (a
206
+ # byte-level mismatch would fail that too). Tested against OpenSSL 3.5+
207
+ # in both cases.
208
+ #
209
+ # @param raw_public_key [String] Raw FIPS 204 public key bytes
210
+ # @param algorithm [String] Linzer's lowercase algorithm identifier
211
+ # @return [OpenSSL::PKey::PKey]
212
+ def wrap_raw_public_key(raw_public_key, algorithm)
213
+ spki = OpenSSL::ASN1::Sequence.new([
214
+ ml_dsa_algorithm_identifier(algorithm),
215
+ OpenSSL::ASN1::BitString.new(raw_public_key)
216
+ ])
217
+ OpenSSL::PKey.read(spki.to_der)
218
+ end
219
+
220
+ # Builds an OpenSSL key from a raw FIPS 204 ML-DSA private key.
221
+ #
222
+ # {OpenSSL::PKey.new_raw_private_key} doesn't accept ML-DSA algorithm
223
+ # names yet, and unlike the public key, a fixed DER prefix won't work
224
+ # here: OpenSSL's PKCS8 encoding embeds a per-key 32-byte seed
225
+ # alongside the expanded key. This builds the seed-free "expandedKey"
226
+ # alternative of the ML-DSA private key CHOICE instead (an untagged
227
+ # OCTET STRING), which carries no per-key data.
228
+ #
229
+ # @param raw_private_key [String] Raw FIPS 204 private key bytes
230
+ # @param algorithm [String] Linzer's lowercase algorithm identifier
231
+ # @return [OpenSSL::PKey::PKey]
232
+ def wrap_raw_private_key(raw_private_key, algorithm)
233
+ expanded_key_choice = OpenSSL::ASN1::OctetString.new(raw_private_key).to_der
234
+ one_asymmetric_key = OpenSSL::ASN1::Sequence.new([
235
+ OpenSSL::ASN1::Integer(0),
236
+ ml_dsa_algorithm_identifier(algorithm),
237
+ OpenSSL::ASN1::OctetString.new(expanded_key_choice)
238
+ ])
239
+ OpenSSL::PKey.read(one_asymmetric_key.to_der)
240
+ end
241
+
242
+ # @param algorithm [String] Linzer's lowercase algorithm identifier
243
+ # @return [OpenSSL::ASN1::Sequence] the ML-DSA AlgorithmIdentifier
244
+ def ml_dsa_algorithm_identifier(algorithm)
245
+ OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId(OPENSSL_OIDS.fetch(algorithm))])
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ # Cross-checks the underlying OpenSSL key material against the
252
+ # claimed {algorithm}. Only actually able to catch a mismatch when
253
+ # `material` came in as PEM/DER (via {OpenSSL::PKey.read}) for a
254
+ # *different* ML-DSA parameter set than requested, material built
255
+ # via {deserialize_raw_or_encoded_key}'s raw-byte path or
256
+ # `OpenSSL::PKey.generate_key(algorithm.upcase)` is already
257
+ # guaranteed consistent by construction, so this is a no-op for
258
+ # those (cheap: `public_to_der` on an already-parsed key, no network
259
+ # or extra crypto). Best-effort: if the material's own OID can't be
260
+ # determined at all, doesn't fail the whole key over it, something
261
+ # downstream (sign/verify) will surface a real problem regardless.
262
+ # @raise [Error] If key material is nil, or its actual algorithm
263
+ # doesn't match {algorithm}
264
+ def validate
265
+ super
266
+ expected_oid = OPENSSL_OIDS.fetch(algorithm) { raise Error, "Unsupported ML-DSA algorithm: #{algorithm}" }
267
+ actual_oid = material_openssl_oid
268
+ return if actual_oid.nil? || actual_oid == expected_oid
269
+
270
+ raise Error, "ML-DSA key material (#{actual_oid}) does not match algorithm #{algorithm}"
271
+ end
272
+
273
+ # @return [String, nil] The dotted OID of `material`'s own
274
+ # AlgorithmIdentifier, or nil if it can't be determined
275
+ def material_openssl_oid
276
+ spki = OpenSSL::ASN1.decode(material.public_to_der)
277
+ spki.value[0].value[0].oid
278
+ rescue OpenSSL::ASN1::ASN1Error, OpenSSL::PKey::PKeyError
279
+ nil
280
+ end
281
+
282
+ # @return [Boolean] true if this key contains public key material
283
+ def compute_public?
284
+ has_pem_public?
285
+ end
286
+
287
+ # @return [Boolean] true if this key contains private key material
288
+ def compute_private?
289
+ has_pem_private?
290
+ end
291
+ end
292
+
293
+ class << self
294
+ # Checks whether this OpenSSL build can actually perform ML-DSA
295
+ # signing/verification for the given algorithm.
296
+ #
297
+ # Returns true only when the algorithm is *both* something this
298
+ # OpenSSL-backed implementation has actually implemented (see
299
+ # {IMPLEMENTED_ALGORITHMS}) *and* something the underlying OpenSSL
300
+ # library itself supports. This is needed mostly because a
301
+ # version-number check alone isn't reliable and some distributions ship
302
+ # OpenSSL 3.5+ with ML-DSA disabled by crypto policy (see
303
+ # https://github.com/ruby/openssl/issues/1075), so this actually
304
+ # attempts a throwaway key generation rather than inspecting
305
+ # `OpenSSL::OPENSSL_VERSION`.
306
+ #
307
+ # Memoized per algorithm after the first check, so repeated calls are
308
+ # free. Unknown or not-yet-implemented algorithm identifiers return
309
+ # `false` rather than raising.
310
+ #
311
+ # Kept at the {Linzer::MLDSA} module level, not under {OpenSSLKey},
312
+ # since it's a backend-selection capability probe used to decide
313
+ # *whether* to construct an {OpenSSLKey} at all, not a detail of an
314
+ # already-chosen OpenSSL backend.
315
+ #
316
+ # @param algorithm [String] Linzer's lowercase algorithm identifier,
317
+ # e.g. `"ml-dsa-44"`
318
+ # @return [Boolean]
319
+ def openssl_supported?(algorithm)
320
+ cache = (@openssl_supported ||= {})
321
+ return cache[algorithm] if cache.key?(algorithm)
322
+
323
+ cache[algorithm] =
324
+ if IMPLEMENTED_ALGORITHMS.include?(algorithm)
325
+ begin
326
+ OpenSSL::PKey.generate_key(algorithm.upcase)
327
+ true
328
+ rescue OpenSSL::PKey::PKeyError
329
+ false
330
+ end
331
+ else
332
+ false
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
data/lib/linzer/ml_dsa.rb CHANGED
@@ -1,102 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "ml_dsa/openssl_key"
4
+ require_relative "ml_dsa/gem_key"
5
+
3
6
  module Linzer
4
- # ML-DSA support for HTTP Message Signatures as specified by https://c2sp.org/httpsig-pq
7
+ # ML-DSA support for HTTP Message Signatures as specified by
8
+ # https://c2sp.org/httpsig-pq
9
+ #
10
+ # Two independent backends live under this namespace, both supporting
11
+ # all three FIPS 204 parameter sets (ML-DSA-44/65/87):
12
+ #
13
+ # - {Linzer::MLDSA::OpenSSLKey} -- backed directly by OpenSSL 3.5+, no
14
+ # extra gem dependency.
15
+ # - {Linzer::MLDSA::GemKey} -- backed by the `ml_dsa` gem.
16
+ #
17
+ # `require "linzer"` alone already gets you OpenSSLKey for free when the
18
+ # running OpenSSL build supports it, that's the safe default, and this
19
+ # file plays no part in it (lib/linzer.rb requires ml_dsa/openssl_key
20
+ # directly). `require "linzer/ml_dsa"` is the explicit opt-in for
21
+ # everything else: it also loads GemKey, which in turn requires the
22
+ # `ml_dsa` gem to be installed. Reach for it when your OpenSSL build is
23
+ # too old for ML-DSA, or you specifically want the gem-backed
24
+ # implementation.
25
+ #
26
+ # `Linzer.generate_ml_dsa_*_key`/`Linzer.new_ml_dsa_*_key` dispatch
27
+ # between them via a `backend:` keyword (:auto, :openssl, or :ml_dsa),
28
+ # preferring OpenSSL when this build actually supports it.
5
29
  #
6
30
  # @see https://c2sp.org/httpsig-pq C2SP post-quantum HTTP signatures
7
31
  # @see https://csrc.nist.gov/pubs/fips/204/final FIPS 204
8
32
  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
33
  end
102
34
  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"
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
 
@@ -27,7 +26,7 @@ require_relative "linzer/rsa_pss"
27
26
  require_relative "linzer/hmac"
28
27
  require_relative "linzer/ed25519"
29
28
  require_relative "linzer/ecdsa"
30
- require_relative "linzer/ml_dsa"
29
+ require_relative "linzer/ml_dsa/openssl_key"
31
30
  require_relative "linzer/key/helper"
32
31
  require_relative "linzer/signer"
33
32
  require_relative "linzer/verifier"
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
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
@@ -206,7 +194,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
206
194
  requirements:
207
195
  - - ">="
208
196
  - !ruby/object:Gem::Version
209
- version: 2.7.2
197
+ version: 2.7.0
210
198
  required_rubygems_version: !ruby/object:Gem::Requirement
211
199
  requirements:
212
200
  - - ">="