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 +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +9 -1
- data/lib/linzer/jws.rb +16 -22
- data/lib/linzer/key/helper.rb +9 -3
- data/lib/linzer/ml_dsa/gem_key.rb +1 -1
- data/lib/linzer/ml_dsa/openssl_key.rb +173 -115
- data/lib/linzer/ml_dsa.rb +14 -5
- data/lib/linzer/version.rb +1 -1
- data/lib/linzer.rb +1 -1
- metadata +2 -2
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,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
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
Base64.urlsafe_encode64(digest, padding: false)
|
|
131
|
+
material.key_digest
|
|
138
132
|
end
|
|
139
133
|
|
|
140
134
|
private
|
data/lib/linzer/key/helper.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
124
|
-
#
|
|
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
|
|
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.
|
|
13
|
-
# - {Linzer::MLDSA::GemKey} -- backed by the `ml_dsa` gem.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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),
|
data/lib/linzer/version.rb
CHANGED
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
|
|
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.
|
|
197
|
+
version: 2.7.0
|
|
198
198
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
199
199
|
requirements:
|
|
200
200
|
- - ">="
|