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 +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +47 -0
- data/lib/linzer/helper.rb +1 -1
- data/lib/linzer/jws.rb +30 -0
- data/lib/linzer/key/helper.rb +110 -22
- data/lib/linzer/ml_dsa/gem_key.rb +120 -0
- data/lib/linzer/ml_dsa/openssl_key.rb +337 -0
- data/lib/linzer/ml_dsa.rb +25 -93
- data/lib/linzer/signature/profile/web_bot_auth.rb +1 -1
- data/lib/linzer/version.rb +1 -1
- data/lib/linzer.rb +1 -2
- metadata +4 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 188e4a68a86287824bb5ba37a0c1b476abb9bd20b7b96048f13f230c070bab08
|
|
4
|
+
data.tar.gz: dc3e0081f6baaaf85d30e532fe7c5cf0bf1cb1e7b566dd8493701b754fabdfa3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
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,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::
|
|
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
|
-
|
|
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::
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
|
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.
|
|
197
|
+
version: 2.7.0
|
|
210
198
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
211
199
|
requirements:
|
|
212
200
|
- - ">="
|