linzer 0.8.1.beta2 → 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: 1bc1cb6232547c649f0c082b444d858632abcb7cd923a50c1bf55ea6da316034
4
- data.tar.gz: 97da42478725e85ab12f9475f2808350dc76981c3f705a8e1479c57b7509f095
3
+ metadata.gz: 188e4a68a86287824bb5ba37a0c1b476abb9bd20b7b96048f13f230c070bab08
4
+ data.tar.gz: dc3e0081f6baaaf85d30e532fe7c5cf0bf1cb1e7b566dd8493701b754fabdfa3
5
5
  SHA512:
6
- metadata.gz: 537e717e95afee1f2c32534ef294608020869b59aa4b07abdc58a629df11c74878390d0f0fa6202bbb7adf913ee70defde827f6f4c8e3c5d50d60c5fd159a1fe
7
- data.tar.gz: 55337d62a400418a8b46bbd240a588e0fa6d66aef18df4c10938b0715919f8b236fb985a3ab0a082cb7fc10f13b27051f261daecf48a469b33ff11c5d7a19b7f
6
+ metadata.gz: 2af5b16fa7832bce168d2fa024dbbe28adfff00d126b520c87e241348eb34f9f1133fd98a85ca3fec6cfd945f773165e0cfe3230420e086a91f1f59fa4eb67b8
7
+ data.tar.gz: 386f5fb8bfe0ddea3d045c66ed52d52a9dab9704c79662ada309027185b67cf0cd6887e5c4becaa64a8410c1aa4419c2274c6739d29743d74d9854da79f3e4c8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
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
+
3
63
  ## [0.8.1.beta2] - 2026-08-14
4
64
 
5
65
  - Add an OpenSSL-native ML-DSA backend, preferred by default over the
data/README.md CHANGED
@@ -584,7 +584,7 @@ gem "ml_dsa"
584
584
  ```
585
585
 
586
586
  ```ruby
587
- require "linzer/ml_dsa/gem_key"
587
+ require "linzer/ml_dsa"
588
588
 
589
589
  key = Linzer.generate_ml_dsa_44_key
590
590
  ```
@@ -593,6 +593,14 @@ Pass `backend: :openssl` or `backend: :ml_dsa` to any `generate_ml_dsa_*`/
593
593
  `new_ml_dsa_*` method to force a specific implementation instead of
594
594
  relying on auto-selection.
595
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
+
596
604
  ### JSON Web Signature (JWS) algorithms
597
605
 
598
606
  Of the JSON Web Signature (JWS) algorithms mentioned in RFC 9421,
data/lib/linzer/jws.rb CHANGED
@@ -3,8 +3,18 @@
3
3
  require "jwt"
4
4
  require "jwt/eddsa"
5
5
  require "ed25519"
6
- require "digest"
7
- require "base64"
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
8
18
 
9
19
  module Linzer
10
20
  # JSON Web Signature (JWS) compatible key support.
@@ -106,35 +116,19 @@ module Linzer
106
116
  # Computes the RFC 7638 JWK SHA-256 Thumbprint for this key's public
107
117
  # material.
108
118
  #
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
119
  # @return [String] base64url-encoded (no padding) SHA-256 thumbprint
117
120
  # @raise [Error] if this key's JWK "kty" is not supported
118
121
  #
119
122
  # @see https://www.rfc-editor.org/rfc/rfc7638 RFC 7638 - JSON Web Key (JWK) Thumbprint
120
123
  # @see https://www.rfc-editor.org/rfc/rfc8037 RFC 8037 - EdDSA for JWS/JWK
121
124
  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
125
  exported = material.export
127
126
 
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
127
+ if exported[:kty] != "OKP"
128
+ raise Error, "Unsupported JWK kty for thumbprint: #{exported[:kty]}"
129
+ end
135
130
 
136
- digest = Digest::SHA256.digest(JWT::JSON.generate(members))
137
- Base64.urlsafe_encode64(digest, padding: false)
131
+ material.key_digest
138
132
  end
139
133
 
140
134
  private
@@ -349,17 +349,21 @@ module Linzer
349
349
  ensure_ml_dsa_gem_key_available!
350
350
  generate_ml_dsa_key_via_gem(algorithm, key_id)
351
351
  end
352
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error => e
353
+ raise Linzer::Error, e.message, cause: e
352
354
  end
353
355
 
354
356
  def new_ml_dsa_key(material, algorithm, key_id, backend)
355
357
  case resolve_ml_dsa_backend(algorithm, backend)
356
358
  when :openssl
357
- key = Linzer::MLDSA.deserialize_raw_or_encoded_key(material, algorithm)
359
+ key = Linzer::MLDSA::OpenSSLKey.deserialize_raw_or_encoded_key(material, algorithm)
358
360
  Linzer::MLDSA::OpenSSLKey.new(key, id: key_id, algorithm: algorithm)
359
361
  when :ml_dsa
360
362
  ensure_ml_dsa_gem_key_available!
361
363
  new_ml_dsa_key_via_gem(material, algorithm, key_id)
362
364
  end
365
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error, TypeError => e
366
+ raise Linzer::Error, e.message, cause: e
363
367
  end
364
368
 
365
369
  # NOTE: ensure_ml_dsa_gem_key_available! must run in the caller, not
@@ -413,13 +417,15 @@ module Linzer
413
417
  end
414
418
 
415
419
  # @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)
420
+ # gem backend is opt-in via `require "linzer/ml_dsa"`, see
421
+ # lib/linzer/ml_dsa.rb)
417
422
  def ensure_ml_dsa_gem_key_available!
418
423
  return if defined?(Linzer::MLDSA::GemKey)
419
424
 
420
425
  raise Linzer::Error,
421
426
  "ML-DSA gem backend not available: " \
422
- 'require "linzer/ml_dsa/gem_key" first'
427
+ 'require "linzer/ml_dsa" first (needs the ml_dsa gem installed; ' \
428
+ 'plain require "linzer" only gets you the OpenSSL backend)'
423
429
  end
424
430
 
425
431
  def deserialize_ml_dsa_key(material, parameter_set)
@@ -20,7 +20,7 @@ module Linzer
20
20
  # Supports all three FIPS 204 parameter sets. Optional: not required
21
21
  # by `linzer.rb` itself, and not a runtime dependency of the gemspec,
22
22
  # callers who want this backend must add `ml_dsa` to their own
23
- # Gemfile and `require "linzer/ml_dsa/gem_key"` themselves, which
23
+ # Gemfile and `require "linzer/ml_dsa"` themselves, which
24
24
  # requires `ml_dsa` in turn.
25
25
  #
26
26
  # @see Linzer::MLDSA::OpenSSLKey for the dependency-free alternative
@@ -2,19 +2,6 @@
2
2
 
3
3
  module Linzer
4
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
5
  # Linzer algorithm identifiers (lowercase, e.g. "ml-dsa-44") that this
19
6
  # OpenSSL-backed implementation actually has construction/OID support
20
7
  # for today. {openssl_supported?} treats anything outside this set as
@@ -23,26 +10,6 @@ module Linzer
23
10
  IMPLEMENTED_ALGORITHMS = %w[ml-dsa-44 ml-dsa-65 ml-dsa-87].freeze
24
11
  private_constant :IMPLEMENTED_ALGORITHMS
25
12
 
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
13
  # ML-DSA (FIPS 204) signing/verification backed directly by OpenSSL
47
14
  # 3.5+, with no additional gem dependency. Supports all three
48
15
  # parameter sets (ML-DSA-44/65/87).
@@ -61,6 +28,39 @@ module Linzer
61
28
  # C2SP httpsig-pq: Post-Quantum Algorithms for HTTP Message Signatures
62
29
  # @see https://csrc.nist.gov/pubs/fips/204/final FIPS 204
63
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
64
  # @return [String] The FIPS 204 parameter set this key was
65
65
  # constructed for, e.g. `"ml-dsa-44"`
66
66
  attr_reader :algorithm
@@ -92,10 +92,13 @@ module Linzer
92
92
  #
93
93
  # @param data [String] The data to sign (typically the signature base)
94
94
  # @return [String] The FIPS 204 signature
95
- # @raise [SigningError] If this key does not contain private key material
95
+ # @raise [SigningError] If this key does not contain private key
96
+ # material, or the underlying OpenSSL signing operation fails
96
97
  def sign(data)
97
98
  validate_signing_key
98
99
  material.sign(nil, data)
100
+ rescue OpenSSL::PKey::PKeyError => e
101
+ raise SigningError, e.message, cause: e
99
102
  end
100
103
 
101
104
  # Verifies a signature using the ML-DSA public key.
@@ -103,10 +106,16 @@ module Linzer
103
106
  # @param signature [String] The signature bytes to verify
104
107
  # @param data [String] The data that was signed
105
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)
106
111
  # @raise [VerifyError] If this key does not contain public key material
107
112
  def verify(signature, data)
108
113
  validate_verify_key
114
+ return false unless signature.is_a?(String)
115
+
109
116
  material.verify(nil, signature, data)
117
+ rescue OpenSSL::PKey::PKeyError, ArgumentError, TypeError
118
+ false
110
119
  end
111
120
 
112
121
  # @return [Symbol] :openssl -- which backend produced this key
@@ -114,14 +123,137 @@ module Linzer
114
123
  :openssl
115
124
  end
116
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
+
117
249
  private
118
250
 
119
251
  # Cross-checks the underlying OpenSSL key material against the
120
252
  # claimed {algorithm}. Only actually able to catch a mismatch when
121
253
  # `material` came in as PEM/DER (via {OpenSSL::PKey.read}) for a
122
254
  # *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
255
+ # via {deserialize_raw_or_encoded_key}'s raw-byte path or
256
+ # `OpenSSL::PKey.generate_key(algorithm.upcase)` is already
125
257
  # guaranteed consistent by construction, so this is a no-op for
126
258
  # those (cheap: `public_to_der` on an already-parsed key, no network
127
259
  # or extra crypto). Best-effort: if the material's own OID can't be
@@ -176,6 +308,11 @@ module Linzer
176
308
  # free. Unknown or not-yet-implemented algorithm identifiers return
177
309
  # `false` rather than raising.
178
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
+ #
179
316
  # @param algorithm [String] Linzer's lowercase algorithm identifier,
180
317
  # e.g. `"ml-dsa-44"`
181
318
  # @return [Boolean]
@@ -195,85 +332,6 @@ module Linzer
195
332
  false
196
333
  end
197
334
  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
335
  end
278
336
  end
279
337
  end
data/lib/linzer/ml_dsa.rb CHANGED
@@ -1,18 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ml_dsa/openssl_key"
4
+ require_relative "ml_dsa/gem_key"
4
5
 
5
6
  module Linzer
6
- # 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
7
9
  #
8
10
  # Two independent backends live under this namespace, both supporting
9
11
  # all three FIPS 204 parameter sets (ML-DSA-44/65/87):
10
12
  #
11
13
  # - {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).
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.
16
25
  #
17
26
  # `Linzer.generate_ml_dsa_*_key`/`Linzer.new_ml_dsa_*_key` dispatch
18
27
  # between them via a `backend:` keyword (:auto, :openssl, or :ml_dsa),
@@ -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.beta2"
6
+ VERSION = "0.8.1"
7
7
  end
data/lib/linzer.rb CHANGED
@@ -26,7 +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
+ require_relative "linzer/ml_dsa/openssl_key"
30
30
  require_relative "linzer/key/helper"
31
31
  require_relative "linzer/signer"
32
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.beta2
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Landaeta
@@ -194,7 +194,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
194
194
  requirements:
195
195
  - - ">="
196
196
  - !ruby/object:Gem::Version
197
- version: 2.7.2
197
+ version: 2.7.0
198
198
  required_rubygems_version: !ruby/object:Gem::Requirement
199
199
  requirements:
200
200
  - - ">="