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