pq_crypto-jwt 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +139 -0
- data/lib/pq_crypto/jwt/algorithms/ml_dsa_44.rb +3 -0
- data/lib/pq_crypto/jwt/algorithms/ml_dsa_65.rb +3 -0
- data/lib/pq_crypto/jwt/algorithms/ml_dsa_87.rb +3 -0
- data/lib/pq_crypto/jwt/algorithms.rb +3 -0
- data/lib/pq_crypto/jwt/errors.rb +9 -0
- data/lib/pq_crypto/jwt/jwa/ml_dsa_44.rb +19 -0
- data/lib/pq_crypto/jwt/jwa/ml_dsa_65.rb +21 -0
- data/lib/pq_crypto/jwt/jwa/ml_dsa_87.rb +19 -0
- data/lib/pq_crypto/jwt/jwa/ml_dsa_streaming.rb +135 -0
- data/lib/pq_crypto/jwt/jwa.rb +95 -0
- data/lib/pq_crypto/jwt/jwk/akp.rb +104 -0
- data/lib/pq_crypto/jwt/jwk.rb +140 -0
- data/lib/pq_crypto/jwt/jwks.rb +54 -0
- data/lib/pq_crypto/jwt/keys.rb +99 -0
- data/lib/pq_crypto/jwt/version.rb +7 -0
- data/lib/pq_crypto/jwt.rb +75 -0
- metadata +122 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4f383c2f1f0414e4817f80ea6865dc4c9d7acdb512d33e1d3e368b7091f84e9c
|
|
4
|
+
data.tar.gz: 00ed418f44277af5ec23ae1ffb4be8edf909cf40e6d974cafd072fd2fdbd0c4f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a1f3e7e7bea52901a570378619485d0d7981129534738e30972187d02c35aa7061bc644c5b6c91ef006a640c9d483993e2d2382447c3b2e8a7205a241b15e922
|
|
7
|
+
data.tar.gz: c3cc3d4ef8e8b6e73b25cc17bd700f2acfe32b66114cca0b73ee983647856b794824bced8727360769577bbd83e3cc458ce374c2ab00de7886155cbbca46221f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Initial public release
|
|
4
|
+
|
|
5
|
+
### Included
|
|
6
|
+
|
|
7
|
+
- ML-DSA JWS algorithms for `ruby-jwt`:
|
|
8
|
+
- `ML-DSA-44`
|
|
9
|
+
- `ML-DSA-65`
|
|
10
|
+
- `ML-DSA-87`
|
|
11
|
+
- Explicit `PQCrypto::JWT.register!` registration.
|
|
12
|
+
- ML-DSA key generation helper through `PQCrypto::JWT::Keys.generate`.
|
|
13
|
+
- ML-DSA SPKI/PKCS#8 PEM import helpers.
|
|
14
|
+
- Public AKP JWK import/export helpers for ML-DSA verification keys.
|
|
15
|
+
- JWKS construction, lookup, and loader helpers.
|
|
16
|
+
- ML-DSA-65 streaming detached JWS helper.
|
|
17
|
+
- Negative tests for signature tampering, algorithm mismatch, wrong key type, unsupported algorithms, and malformed JWK inputs.
|
|
18
|
+
|
|
19
|
+
### Deliberately not included
|
|
20
|
+
|
|
21
|
+
- ML-KEM JWE key agreement or key wrap.
|
|
22
|
+
- JWE compact or JSON serialization.
|
|
23
|
+
- JWE content encryption, AAD, IV, or authentication tag handling.
|
|
24
|
+
- Private AKP JWK import/export; use PEM/PKCS#8 for signing keys.
|
|
25
|
+
- Experimental draft behavior that is not ready for interoperability testing.
|
|
26
|
+
|
|
27
|
+
### Security status
|
|
28
|
+
|
|
29
|
+
- Unaudited.
|
|
30
|
+
- Backed by `pq_crypto`.
|
|
31
|
+
- Tracks draft ML-DSA JOSE identifiers; identifiers and wire formats may change before RFC publication.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roman Haydarov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# pq_crypto-jwt
|
|
2
|
+
|
|
3
|
+
`pq_crypto-jwt` is a small adapter that connects [`pq_crypto`](https://rubygems.org/gems/pq_crypto) to the [`ruby-jwt`](https://rubygems.org/gems/jwt) ecosystem.
|
|
4
|
+
|
|
5
|
+
The first public release intentionally focuses on one stable surface:
|
|
6
|
+
|
|
7
|
+
- ML-DSA JWS signing and verification for `ruby-jwt`
|
|
8
|
+
- public AKP JWK/JWKS helpers for ML-DSA verification keys
|
|
9
|
+
- PEM import helpers for ML-DSA SPKI/PKCS#8 keys
|
|
10
|
+
- ML-DSA-65 streaming detached JWS helper
|
|
11
|
+
|
|
12
|
+
ML-KEM/JWE is **not included** in this first release. Full JWE support needs a separate standards-compatible implementation and interoperability tests.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "pq_crypto-jwt", "~> 0.1"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Register the algorithms
|
|
21
|
+
|
|
22
|
+
`pq_crypto-jwt` does not register algorithms implicitly. Register once during boot:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
require "pq_crypto/jwt"
|
|
26
|
+
|
|
27
|
+
PQCrypto::JWT.register!
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This registers the following JOSE `alg` values with `ruby-jwt`:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
ML-DSA-44
|
|
34
|
+
ML-DSA-65
|
|
35
|
+
ML-DSA-87
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## JWS — sign and verify with ML-DSA
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "pq_crypto/jwt"
|
|
42
|
+
|
|
43
|
+
PQCrypto::JWT.register!
|
|
44
|
+
keypair = PQCrypto::JWT::Keys.generate("ML-DSA-65")
|
|
45
|
+
|
|
46
|
+
token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65")
|
|
47
|
+
payload, header = JWT.decode(token, keypair.public_key, true, algorithm: "ML-DSA-65")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The adapter validates both the JOSE algorithm string and the concrete pq_crypto key type. A token signed with `ML-DSA-44`, for example, will not verify under `ML-DSA-65`.
|
|
51
|
+
|
|
52
|
+
## PEM import
|
|
53
|
+
|
|
54
|
+
SPKI public keys and PKCS#8 secret keys can be imported through the helper API:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
public_key = PQCrypto::JWT::Keys.public_from_pem(spki_pem)
|
|
58
|
+
secret_key = PQCrypto::JWT::Keys.secret_from_pem(pkcs8_pem)
|
|
59
|
+
|
|
60
|
+
token = JWT.encode({ "sub" => "alice" }, secret_key, "ML-DSA-65")
|
|
61
|
+
JWT.decode(token, public_key, true, algorithm: "ML-DSA-65")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For stricter dispatch, pass `expect: :signature`:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
public_key = PQCrypto::JWT::Keys.public_from_pem(spki_pem, expect: :signature)
|
|
68
|
+
secret_key = PQCrypto::JWT::Keys.secret_from_pem(pkcs8_pem, expect: :signature)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## JWK and JWKS
|
|
72
|
+
|
|
73
|
+
Public AKP JWK round-trip:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
keypair = PQCrypto::JWT::Keys.generate("ML-DSA-65")
|
|
77
|
+
jwk = PQCrypto::JWT::JWK.from_public_key(keypair.public_key, kid: "signing-key")
|
|
78
|
+
public_key = PQCrypto::JWT::JWK.public_key_from_jwk(jwk)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
JWKS lookup with `ruby-jwt`:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
PQCrypto::JWT.register!
|
|
85
|
+
keypair = PQCrypto::JWT::Keys.generate("ML-DSA-65")
|
|
86
|
+
jwks = PQCrypto::JWT::JWKS.from_keys([keypair.public_key], kids: ["signing-key"])
|
|
87
|
+
|
|
88
|
+
token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65", kid: "signing-key")
|
|
89
|
+
payload, header = JWT.decode(token, nil, true, algorithms: ["ML-DSA-65"], jwks: jwks)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For rotation, pass `PQCrypto::JWT::JWKS.loader(callable_or_hash)` as the `jwks:` value.
|
|
93
|
+
|
|
94
|
+
## Streaming detached JWS
|
|
95
|
+
|
|
96
|
+
`ML-DSA-65` also supports a streaming detached JWS helper. The compact form is `header..signature`; callers must supply the same payload stream separately for verification.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
File.open("payload.bin", "rb") do |payload_io|
|
|
100
|
+
token = PQCrypto::JWT::JWA::MLDSA65.sign_io(
|
|
101
|
+
signing_key: keypair.secret_key,
|
|
102
|
+
payload_io: payload_io
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
File.open("payload.bin", "rb") do |payload_io|
|
|
107
|
+
PQCrypto::JWT::JWA::MLDSA65.verify_io!(
|
|
108
|
+
verification_key: keypair.public_key,
|
|
109
|
+
token: token,
|
|
110
|
+
payload_io: payload_io
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Non-goals for the first release
|
|
116
|
+
|
|
117
|
+
The first release deliberately does **not** expose:
|
|
118
|
+
|
|
119
|
+
- ML-KEM JWE key agreement
|
|
120
|
+
- JWE compact or JSON serialization
|
|
121
|
+
- JWE content encryption, AAD, IV, or authentication tag handling
|
|
122
|
+
- private AKP JWK import/export; use PEM/PKCS#8 for signing keys
|
|
123
|
+
- general-purpose JWT claims policy beyond what `ruby-jwt` already provides
|
|
124
|
+
|
|
125
|
+
This keeps the public API small and avoids publishing draft-incompatible JWE behavior.
|
|
126
|
+
|
|
127
|
+
## Security status
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
unaudited; tracks draft-ietf-cose-dilithium for ML-DSA JOSE identifiers;
|
|
131
|
+
identifiers and wire formats may change before RFC publication; backed by
|
|
132
|
+
pq_crypto, which should also be reviewed before production use.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Use in production only after your own security review and interoperability testing.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../jwa"
|
|
4
|
+
|
|
5
|
+
module PQCrypto
|
|
6
|
+
module JWT
|
|
7
|
+
module JWA
|
|
8
|
+
module MLDSA44
|
|
9
|
+
extend ::JWT::JWA::SigningAlgorithm
|
|
10
|
+
extend PQCrypto::JWT::JWA::MLDSA
|
|
11
|
+
|
|
12
|
+
ALG = "ML-DSA-44".freeze
|
|
13
|
+
PQ_CRYPTO_ALGORITHM = :ml_dsa_44
|
|
14
|
+
|
|
15
|
+
def self.alg = ALG
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../jwa"
|
|
4
|
+
require_relative "ml_dsa_streaming"
|
|
5
|
+
|
|
6
|
+
module PQCrypto
|
|
7
|
+
module JWT
|
|
8
|
+
module JWA
|
|
9
|
+
module MLDSA65
|
|
10
|
+
extend ::JWT::JWA::SigningAlgorithm
|
|
11
|
+
extend PQCrypto::JWT::JWA::MLDSA
|
|
12
|
+
extend PQCrypto::JWT::JWA::MLDSAStreaming
|
|
13
|
+
|
|
14
|
+
ALG = "ML-DSA-65".freeze
|
|
15
|
+
PQ_CRYPTO_ALGORITHM = :ml_dsa_65
|
|
16
|
+
|
|
17
|
+
def self.alg = ALG
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../jwa"
|
|
4
|
+
|
|
5
|
+
module PQCrypto
|
|
6
|
+
module JWT
|
|
7
|
+
module JWA
|
|
8
|
+
module MLDSA87
|
|
9
|
+
extend ::JWT::JWA::SigningAlgorithm
|
|
10
|
+
extend PQCrypto::JWT::JWA::MLDSA
|
|
11
|
+
|
|
12
|
+
ALG = "ML-DSA-87".freeze
|
|
13
|
+
PQ_CRYPTO_ALGORITHM = :ml_dsa_87
|
|
14
|
+
|
|
15
|
+
def self.alg = ALG
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module PQCrypto
|
|
7
|
+
module JWT
|
|
8
|
+
module JWA
|
|
9
|
+
module MLDSAStreaming
|
|
10
|
+
DEFAULT_CHUNK_SIZE = 1 << 20
|
|
11
|
+
EMPTY_CONTEXT = "".b.freeze
|
|
12
|
+
|
|
13
|
+
def streaming_supported?
|
|
14
|
+
pq_crypto_algorithm == :ml_dsa_65 &&
|
|
15
|
+
PQCrypto::Signature.supported.include?(:ml_dsa_65)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sign_io(signing_key:, payload_io: nil, io: nil, header_fields: {}, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
19
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "#{alg} does not support streaming JWS" unless streaming_supported?
|
|
20
|
+
|
|
21
|
+
ensure_secret_key!(signing_key)
|
|
22
|
+
source = payload_io || io
|
|
23
|
+
raise ArgumentError, "payload_io must respond to #read" unless source.respond_to?(:read)
|
|
24
|
+
|
|
25
|
+
header = stringify_keys(header_fields || {}).merge("alg" => alg)
|
|
26
|
+
encoded_header = base64url(JSON.generate(header))
|
|
27
|
+
signing_input = DetachedSigningInputIO.new(encoded_header, source, chunk_size: chunk_size)
|
|
28
|
+
signature = signing_key.sign_io(signing_input, chunk_size: chunk_size, context: EMPTY_CONTEXT)
|
|
29
|
+
"#{encoded_header}..#{base64url(signature)}"
|
|
30
|
+
rescue PQCrypto::JWT::Error, ArgumentError
|
|
31
|
+
raise
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise ::JWT::EncodeError, e.message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def verify_io(verification_key:, token:, payload_io:, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
37
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "#{alg} does not support streaming JWS" unless streaming_supported?
|
|
38
|
+
ensure_public_key!(verification_key)
|
|
39
|
+
raise ArgumentError, "token must be a String" unless token.is_a?(String)
|
|
40
|
+
raise ArgumentError, "payload_io must respond to #read" unless payload_io.respond_to?(:read)
|
|
41
|
+
|
|
42
|
+
encoded_header, encoded_payload, encoded_signature = token.split(".", -1)
|
|
43
|
+
return false unless encoded_header && encoded_payload == "" && encoded_signature
|
|
44
|
+
|
|
45
|
+
header = JSON.parse(Base64.urlsafe_decode64(encoded_header))
|
|
46
|
+
return false unless header["alg"] == alg
|
|
47
|
+
|
|
48
|
+
signature = Base64.urlsafe_decode64(encoded_signature)
|
|
49
|
+
signing_input = DetachedSigningInputIO.new(encoded_header, payload_io, chunk_size: chunk_size)
|
|
50
|
+
verified = verification_key.verify_io(signing_input, signature, chunk_size: chunk_size, context: EMPTY_CONTEXT)
|
|
51
|
+
return false unless verified
|
|
52
|
+
|
|
53
|
+
[payload_position(payload_io), header]
|
|
54
|
+
rescue JSON::ParserError, ArgumentError, PQCrypto::InvalidKeyError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def verify_io!(verification_key:, token:, payload_io:, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
59
|
+
result = verify_io(verification_key: verification_key, token: token, payload_io: payload_io, chunk_size: chunk_size)
|
|
60
|
+
raise ::JWT::VerificationError, "Streaming JWS verification failed" unless result
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def base64url(bytes)
|
|
68
|
+
Base64.urlsafe_encode64(String(bytes).b, padding: false)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def stringify_keys(hash)
|
|
72
|
+
hash.each_with_object({}) { |(key, value), out| out[String(key)] = value }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def payload_position(payload_io)
|
|
76
|
+
payload_io.respond_to?(:pos) ? payload_io.pos : nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class DetachedSigningInputIO
|
|
81
|
+
def initialize(encoded_header, payload_io, chunk_size: MLDSAStreaming::DEFAULT_CHUNK_SIZE)
|
|
82
|
+
@prefix = "#{encoded_header}.".b
|
|
83
|
+
@payload_io = payload_io
|
|
84
|
+
@chunk_size = chunk_size
|
|
85
|
+
@buffer = +""
|
|
86
|
+
@carry = +""
|
|
87
|
+
@prefix_done = false
|
|
88
|
+
@payload_done = false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read(length = nil, outbuf = nil)
|
|
92
|
+
length ||= @chunk_size
|
|
93
|
+
fill(length)
|
|
94
|
+
return nil if @buffer.empty?
|
|
95
|
+
|
|
96
|
+
result = @buffer.byteslice(0, length)
|
|
97
|
+
@buffer = @buffer.byteslice(result.bytesize..-1) || +""
|
|
98
|
+
outbuf&.replace(result)
|
|
99
|
+
outbuf || result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def fill(length)
|
|
105
|
+
@buffer << @prefix unless consume_prefix?
|
|
106
|
+
while @buffer.bytesize < length && !@payload_done
|
|
107
|
+
chunk = @payload_io.read(@chunk_size)
|
|
108
|
+
if chunk.nil? || chunk.empty?
|
|
109
|
+
@buffer << Base64.urlsafe_encode64(@carry, padding: false) unless @carry.empty?
|
|
110
|
+
@carry = +""
|
|
111
|
+
@payload_done = true
|
|
112
|
+
break
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
bytes = @carry + chunk.b
|
|
116
|
+
full_length = bytes.bytesize - (bytes.bytesize % 3)
|
|
117
|
+
if full_length.positive?
|
|
118
|
+
@buffer << Base64.urlsafe_encode64(bytes.byteslice(0, full_length), padding: false)
|
|
119
|
+
@carry = bytes.byteslice(full_length..-1) || +""
|
|
120
|
+
else
|
|
121
|
+
@carry = bytes
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def consume_prefix?
|
|
127
|
+
return true if @prefix_done
|
|
128
|
+
|
|
129
|
+
@prefix_done = true
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "pq_crypto"
|
|
5
|
+
|
|
6
|
+
module PQCrypto
|
|
7
|
+
module JWT
|
|
8
|
+
module JWA
|
|
9
|
+
module MLDSA
|
|
10
|
+
def valid_alg?(alg_to_validate)
|
|
11
|
+
alg_to_validate == alg
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pq_crypto_algorithm
|
|
15
|
+
self::PQ_CRYPTO_ALGORITHM
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def streaming_supported?
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sign_io(**)
|
|
23
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "#{alg} does not support streaming JWS"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def verify_io(**)
|
|
27
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "#{alg} does not support streaming JWS"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def verify_io!(**)
|
|
31
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "#{alg} does not support streaming JWS"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def key_kind
|
|
35
|
+
:signature
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sign(data:, signing_key:)
|
|
39
|
+
ensure_secret_key!(signing_key)
|
|
40
|
+
raise ArgumentError, "data must be a String" unless data.is_a?(String)
|
|
41
|
+
|
|
42
|
+
signing_key.sign(data.b)
|
|
43
|
+
rescue PQCrypto::JWT::KeyTypeError, ArgumentError
|
|
44
|
+
raise
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
raise ::JWT::EncodeError, e.message
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def verify(data:, signature:, verification_key:)
|
|
50
|
+
return false unless public_key_for_this_algorithm?(verification_key)
|
|
51
|
+
return false unless data.is_a?(String)
|
|
52
|
+
return false unless signature.is_a?(String)
|
|
53
|
+
return false unless signature_length_valid?(signature)
|
|
54
|
+
|
|
55
|
+
verification_key.verify(data.b, signature.b)
|
|
56
|
+
rescue PQCrypto::InvalidKeyError, PQCrypto::JWT::Error, ArgumentError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def ensure_secret_key!(key)
|
|
63
|
+
unless key.is_a?(PQCrypto::Signature::SecretKey)
|
|
64
|
+
raise PQCrypto::JWT::KeyTypeError,
|
|
65
|
+
"#{alg} signing requires PQCrypto::Signature::SecretKey"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return if key.algorithm == pq_crypto_algorithm
|
|
69
|
+
|
|
70
|
+
raise PQCrypto::JWT::KeyTypeError,
|
|
71
|
+
"#{alg} signing requires #{pq_crypto_algorithm.inspect} key, got #{key.algorithm.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ensure_public_key!(key)
|
|
75
|
+
unless public_key_for_this_algorithm?(key)
|
|
76
|
+
raise PQCrypto::JWT::KeyTypeError,
|
|
77
|
+
"#{alg} verification requires PQCrypto::Signature::PublicKey for #{pq_crypto_algorithm.inspect}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def public_key_for_this_algorithm?(key)
|
|
82
|
+
key.is_a?(PQCrypto::Signature::PublicKey) && key.algorithm == pq_crypto_algorithm
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def signature_length_valid?(signature)
|
|
86
|
+
details = PQCrypto::Signature.details(pq_crypto_algorithm)
|
|
87
|
+
expected = details[:signature_bytes] || details["signature_bytes"]
|
|
88
|
+
expected.nil? || signature.bytesize == expected
|
|
89
|
+
rescue StandardError
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt/jwk/key_base"
|
|
4
|
+
|
|
5
|
+
module JWT
|
|
6
|
+
module JWK
|
|
7
|
+
class AKP < KeyBase
|
|
8
|
+
KTY = "AKP".freeze
|
|
9
|
+
KTYS = [
|
|
10
|
+
KTY,
|
|
11
|
+
PQCrypto::Signature::PublicKey,
|
|
12
|
+
JWT::JWK::AKP,
|
|
13
|
+
].freeze
|
|
14
|
+
AKP_KEY_ELEMENTS = %i[kty alg pub priv].freeze
|
|
15
|
+
|
|
16
|
+
class NullKidGenerator
|
|
17
|
+
def initialize(_jwk); end
|
|
18
|
+
|
|
19
|
+
def generate = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(key, params = nil, options = {})
|
|
23
|
+
params ||= {}
|
|
24
|
+
options ||= {}
|
|
25
|
+
options = { kid_generator: NullKidGenerator }.merge(options)
|
|
26
|
+
params = { kid: params } if params.is_a?(String)
|
|
27
|
+
key_params = extract_key_params(key)
|
|
28
|
+
params = params.transform_keys(&:to_sym)
|
|
29
|
+
check_jwk_params!(key_params, params)
|
|
30
|
+
super(options, key_params.merge(params))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def private?
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def public_key
|
|
38
|
+
@public_key ||= PQCrypto::JWT::JWK.public_key_from_jwk(string_export)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def signing_key
|
|
42
|
+
public_key
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def verify_key
|
|
46
|
+
public_key
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def export(_options = {})
|
|
50
|
+
parameters.clone.tap { |exported| exported.delete(:priv) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def members
|
|
54
|
+
%i[alg kty pub].each_with_object({}) { |key, out| out[key] = self[key] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def key_digest
|
|
58
|
+
PQCrypto::JWT::JWK.thumbprint(string_export)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def jwa
|
|
62
|
+
PQCrypto::JWT.algorithm_for(self[:alg]) || super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def []=(key, value)
|
|
66
|
+
raise ArgumentError, "cannot overwrite cryptographic key attributes" if AKP_KEY_ELEMENTS.include?(key.to_sym)
|
|
67
|
+
|
|
68
|
+
super
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def string_export
|
|
74
|
+
export.transform_keys(&:to_s)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_key_params(key)
|
|
78
|
+
case key
|
|
79
|
+
when JWT::JWK::AKP
|
|
80
|
+
key.export
|
|
81
|
+
when Hash
|
|
82
|
+
key.transform_keys(&:to_sym)
|
|
83
|
+
when PQCrypto::Signature::PublicKey
|
|
84
|
+
PQCrypto::JWT::JWK.from_public_key(key).transform_keys(&:to_sym)
|
|
85
|
+
when PQCrypto::Signature::Keypair, PQCrypto::Signature::SecretKey
|
|
86
|
+
raise JWT::JWKError, "AKP private JWK export is not supported in the first release"
|
|
87
|
+
else
|
|
88
|
+
raise ArgumentError, "key must be a public AKP JWK Hash or PQCrypto::Signature::PublicKey"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def check_jwk_params!(key_params, params)
|
|
93
|
+
raise ArgumentError, "cannot overwrite cryptographic key attributes" unless (AKP_KEY_ELEMENTS & params.keys).empty?
|
|
94
|
+
raise JWT::JWKError, "Incorrect 'kty' value: #{key_params[:kty]}, expected #{KTY}" unless key_params[:kty] == KTY
|
|
95
|
+
raise JWT::JWKError, "AKP JWK alg is required" unless key_params[:alg]
|
|
96
|
+
raise JWT::JWKError, "AKP JWK pub is required" unless key_params[:pub]
|
|
97
|
+
raise JWT::JWKError, "AKP private JWK import is not supported in the first release" if key_params[:priv]
|
|
98
|
+
raise JWT::JWKError, "Unsupported AKP JWK alg: #{key_params[:alg].inspect}" unless PQCrypto::JWT.algorithm_for(key_params[:alg])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
JWT::JWK.classes.delete(JWT::JWK::AKP)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module PQCrypto
|
|
8
|
+
module JWT
|
|
9
|
+
module JWK
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
KTY = "AKP".freeze
|
|
13
|
+
|
|
14
|
+
def from_public_key(public_key, kid: nil)
|
|
15
|
+
validate_public_key!(public_key)
|
|
16
|
+
base_public_jwk(public_key.algorithm, public_key.to_bytes, kid: kid).freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def from_secret_key(*, **)
|
|
20
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm,
|
|
21
|
+
"Private AKP JWK export is not supported in the first release; use PEM/PKCS#8 for signing keys"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def public_key_from_jwk(hash)
|
|
25
|
+
jwk = normalize_hash!(hash)
|
|
26
|
+
reject_private_material!(jwk)
|
|
27
|
+
algorithm = algorithm_from_jwk!(jwk)
|
|
28
|
+
public_bytes = decode_required_key_bytes!(jwk, "pub", algorithm, :public_key_bytes)
|
|
29
|
+
PQCrypto::Signature.public_key_from_bytes(algorithm, public_bytes)
|
|
30
|
+
rescue ArgumentError => e
|
|
31
|
+
raise PQCrypto::JWT::Error, e.message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def secret_key_from_jwk(*)
|
|
35
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm,
|
|
36
|
+
"Private AKP JWK import is not supported in the first release; use PEM/PKCS#8 for signing keys"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def thumbprint(jwk_hash)
|
|
40
|
+
jwk = normalize_hash!(jwk_hash)
|
|
41
|
+
reject_private_material!(jwk)
|
|
42
|
+
algorithm_from_jwk!(jwk)
|
|
43
|
+
raise PQCrypto::JWT::Error, "JWK pub is required" unless jwk.key?("pub")
|
|
44
|
+
|
|
45
|
+
canonical = JSON.generate({ "alg" => jwk.fetch("alg"), "kty" => KTY, "pub" => jwk.fetch("pub") })
|
|
46
|
+
base64url(Digest::SHA256.digest(canonical.b))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def base64url(bytes)
|
|
50
|
+
Base64.urlsafe_encode64(String(bytes).b, padding: false)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def base64url_decode(value)
|
|
54
|
+
Base64.urlsafe_decode64(String(value))
|
|
55
|
+
rescue ArgumentError => e
|
|
56
|
+
raise PQCrypto::JWT::Error, "Invalid base64url value: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_hash!(hash)
|
|
60
|
+
unless hash.respond_to?(:to_hash)
|
|
61
|
+
raise PQCrypto::JWT::Error, "JWK must be a Hash-like object"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
hash.to_hash.each_with_object({}) do |(key, value), normalized|
|
|
65
|
+
normalized[String(key)] = value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reject_private_material!(jwk)
|
|
70
|
+
return unless jwk.key?("priv")
|
|
71
|
+
|
|
72
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm,
|
|
73
|
+
"Private AKP JWK material is not supported in the first release"
|
|
74
|
+
end
|
|
75
|
+
private_class_method :reject_private_material!
|
|
76
|
+
|
|
77
|
+
def algorithm_from_jwk!(jwk)
|
|
78
|
+
unless jwk.fetch("kty", nil) == KTY
|
|
79
|
+
raise PQCrypto::JWT::Error, "Unsupported JWK kty: #{jwk.fetch('kty', nil).inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
alg = jwk.fetch("alg", nil)
|
|
83
|
+
algorithm = PQCrypto::JWT.algorithm_for(alg)
|
|
84
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "Unsupported JWK alg: #{alg.inspect}" unless algorithm
|
|
85
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "Unsupported JWK alg: #{alg.inspect}" unless algorithm.key_kind == :signature
|
|
86
|
+
|
|
87
|
+
algorithm.pq_crypto_algorithm
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def alg_for_algorithm!(algorithm)
|
|
91
|
+
match = PQCrypto::JWT.signing_algorithms.find { |candidate| candidate.pq_crypto_algorithm == algorithm }
|
|
92
|
+
raise PQCrypto::JWT::UnsupportedAlgorithm, "Unsupported pq_crypto signature algorithm: #{algorithm.inspect}" unless match
|
|
93
|
+
|
|
94
|
+
match.alg
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def base_public_jwk(algorithm, public_bytes, kid: nil)
|
|
98
|
+
jwk = {
|
|
99
|
+
"kty" => KTY,
|
|
100
|
+
"alg" => alg_for_algorithm!(algorithm),
|
|
101
|
+
"pub" => base64url(public_bytes),
|
|
102
|
+
}
|
|
103
|
+
jwk["kid"] = String(kid) unless kid.nil?
|
|
104
|
+
jwk
|
|
105
|
+
end
|
|
106
|
+
private_class_method :base_public_jwk
|
|
107
|
+
|
|
108
|
+
def decode_required_key_bytes!(jwk, field, algorithm, detail_key)
|
|
109
|
+
raise PQCrypto::JWT::Error, "JWK #{field} is required" unless jwk.key?(field)
|
|
110
|
+
|
|
111
|
+
decoded = base64url_decode(jwk.fetch(field))
|
|
112
|
+
details = PQCrypto::Signature.details(algorithm)
|
|
113
|
+
expected = details.fetch(detail_key) { details.fetch(detail_key.to_s) }
|
|
114
|
+
unless decoded.bytesize == expected
|
|
115
|
+
raise PQCrypto::JWT::Error,
|
|
116
|
+
"Invalid #{field} length for #{algorithm.inspect}: expected #{expected}, got #{decoded.bytesize}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
decoded.b
|
|
120
|
+
end
|
|
121
|
+
private_class_method :decode_required_key_bytes!
|
|
122
|
+
|
|
123
|
+
def validate_public_key!(public_key)
|
|
124
|
+
unless public_key.is_a?(PQCrypto::Signature::PublicKey)
|
|
125
|
+
raise PQCrypto::JWT::KeyTypeError, "Expected PQCrypto::Signature::PublicKey"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
validate_algorithm!(public_key.algorithm)
|
|
129
|
+
end
|
|
130
|
+
private_class_method :validate_public_key!
|
|
131
|
+
|
|
132
|
+
def validate_algorithm!(algorithm)
|
|
133
|
+
return if PQCrypto::JWT.signing_algorithms.any? { |candidate| candidate.pq_crypto_algorithm == algorithm }
|
|
134
|
+
|
|
135
|
+
raise PQCrypto::JWT::KeyTypeError, "Unsupported signature algorithm: #{algorithm.inspect}"
|
|
136
|
+
end
|
|
137
|
+
private_class_method :validate_algorithm!
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PQCrypto
|
|
4
|
+
module JWT
|
|
5
|
+
module JWKS
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def from_keys(public_keys, kids: nil)
|
|
9
|
+
keys = Array(public_keys).each_with_index.map do |public_key, index|
|
|
10
|
+
kid = kids&.fetch(index, nil)
|
|
11
|
+
PQCrypto::JWT::JWK.from_public_key(public_key, kid: kid)
|
|
12
|
+
end
|
|
13
|
+
{ "keys" => keys }.freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find(jwks, kid: nil, alg: nil)
|
|
17
|
+
keys_from(jwks).find do |key|
|
|
18
|
+
(kid.nil? || value_for(key, "kid") == kid) &&
|
|
19
|
+
(alg.nil? || value_for(key, "alg") == alg)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def loader(jwks_hash_or_callable)
|
|
24
|
+
cached = nil
|
|
25
|
+
lambda do |options = {}|
|
|
26
|
+
if jwks_hash_or_callable.respond_to?(:call)
|
|
27
|
+
cached = nil if options && options[:invalidate]
|
|
28
|
+
cached ||= jwks_hash_or_callable.call(options || {})
|
|
29
|
+
else
|
|
30
|
+
cached = nil if options && options[:invalidate]
|
|
31
|
+
cached ||= jwks_hash_or_callable
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def keys_from(jwks)
|
|
37
|
+
source = jwks.respond_to?(:to_hash) ? jwks.to_hash : jwks
|
|
38
|
+
keys = source["keys"] || source[:keys] if source.respond_to?(:[])
|
|
39
|
+
Array(keys).map { |key| stringify_hash(key) }
|
|
40
|
+
end
|
|
41
|
+
private_class_method :keys_from
|
|
42
|
+
|
|
43
|
+
def stringify_hash(hash)
|
|
44
|
+
hash.to_hash.each_with_object({}) { |(key, value), out| out[String(key)] = value }
|
|
45
|
+
end
|
|
46
|
+
private_class_method :stringify_hash
|
|
47
|
+
|
|
48
|
+
def value_for(hash, key)
|
|
49
|
+
hash[key] || hash[key.to_sym]
|
|
50
|
+
end
|
|
51
|
+
private_class_method :value_for
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module PQCrypto
|
|
8
|
+
module JWT
|
|
9
|
+
module Keys
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
EXPECT_VALUES = [:auto, :signature].freeze
|
|
13
|
+
|
|
14
|
+
def generate(alg)
|
|
15
|
+
algorithm = PQCrypto::JWT.algorithm_for(alg)
|
|
16
|
+
raise ArgumentError, "Unsupported PQCrypto JOSE algorithm: #{alg.inspect}" unless algorithm
|
|
17
|
+
raise ArgumentError, "Unsupported key kind for #{alg.inspect}" unless algorithm.key_kind == :signature
|
|
18
|
+
|
|
19
|
+
PQCrypto::Signature.generate(algorithm.pq_crypto_algorithm)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def public_from_pem(pem, expect: :auto)
|
|
23
|
+
validate_expect!(expect)
|
|
24
|
+
return PQCrypto::Signature.public_key_from_spki_pem(pem) if expect == :signature
|
|
25
|
+
|
|
26
|
+
dispatch_public_from_pem(pem)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def secret_from_pem(pem, expect: :auto)
|
|
30
|
+
validate_expect!(expect)
|
|
31
|
+
return PQCrypto::Signature.secret_key_from_pkcs8_pem(pem) if expect == :signature
|
|
32
|
+
|
|
33
|
+
dispatch_secret_from_pem(pem)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_expect!(expect)
|
|
37
|
+
return if EXPECT_VALUES.include?(expect)
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "expect: must be one of #{EXPECT_VALUES.map(&:inspect).join(', ')}"
|
|
40
|
+
end
|
|
41
|
+
private_class_method :validate_expect!
|
|
42
|
+
|
|
43
|
+
def dispatch_public_from_pem(pem)
|
|
44
|
+
oid = spki_oid_from_pem(pem)
|
|
45
|
+
return PQCrypto::Signature.public_key_from_spki_pem(pem) if signature_oids.include?(oid.to_s)
|
|
46
|
+
|
|
47
|
+
raise PQCrypto::JWT::Error, "Unknown or unsupported PQCrypto SPKI algorithm OID: #{oid.inspect}"
|
|
48
|
+
end
|
|
49
|
+
private_class_method :dispatch_public_from_pem
|
|
50
|
+
|
|
51
|
+
def dispatch_secret_from_pem(pem)
|
|
52
|
+
oid = pkcs8_oid_from_pem(pem)
|
|
53
|
+
return PQCrypto::Signature.secret_key_from_pkcs8_pem(pem) if signature_oids.include?(oid.to_s)
|
|
54
|
+
|
|
55
|
+
raise PQCrypto::JWT::Error, "Unknown or unsupported PQCrypto PKCS#8 algorithm OID: #{oid.inspect}"
|
|
56
|
+
end
|
|
57
|
+
private_class_method :dispatch_secret_from_pem
|
|
58
|
+
|
|
59
|
+
def spki_oid_from_pem(pem)
|
|
60
|
+
sequence = OpenSSL::ASN1.decode(pem_to_der(pem))
|
|
61
|
+
sequence.value.fetch(0).value.fetch(0).oid
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
raise PQCrypto::JWT::Error, "Unable to read SPKI algorithm OID: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
private_class_method :spki_oid_from_pem
|
|
66
|
+
|
|
67
|
+
def pkcs8_oid_from_pem(pem)
|
|
68
|
+
sequence = OpenSSL::ASN1.decode(pem_to_der(pem))
|
|
69
|
+
sequence.value.fetch(1).value.fetch(0).oid
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
raise PQCrypto::JWT::Error, "Unable to read PKCS#8 algorithm OID: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
private_class_method :pkcs8_oid_from_pem
|
|
74
|
+
|
|
75
|
+
def pem_to_der(pem)
|
|
76
|
+
body = pem.to_s.lines.reject { |line| line.start_with?("-----") }.join
|
|
77
|
+
Base64.decode64(body)
|
|
78
|
+
end
|
|
79
|
+
private_class_method :pem_to_der
|
|
80
|
+
|
|
81
|
+
def signature_oids
|
|
82
|
+
@signature_oids ||= PQCrypto::JWT.signing_algorithms.filter_map do |algorithm|
|
|
83
|
+
oid_for_algorithm(algorithm.pq_crypto_algorithm)
|
|
84
|
+
end.to_set
|
|
85
|
+
end
|
|
86
|
+
private_class_method :signature_oids
|
|
87
|
+
|
|
88
|
+
def oid_for_algorithm(algorithm)
|
|
89
|
+
return unless PQCrypto.const_defined?(:AlgorithmRegistry)
|
|
90
|
+
|
|
91
|
+
oid = PQCrypto::AlgorithmRegistry.standard_oid(algorithm)
|
|
92
|
+
oid&.to_s
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
private_class_method :oid_for_algorithm
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "pq_crypto"
|
|
6
|
+
|
|
7
|
+
require_relative "jwt/version"
|
|
8
|
+
require_relative "jwt/errors"
|
|
9
|
+
require_relative "jwt/jwa"
|
|
10
|
+
require_relative "jwt/jwa/ml_dsa_44"
|
|
11
|
+
require_relative "jwt/jwa/ml_dsa_65"
|
|
12
|
+
require_relative "jwt/jwa/ml_dsa_87"
|
|
13
|
+
require_relative "jwt/keys"
|
|
14
|
+
require_relative "jwt/jwk"
|
|
15
|
+
require_relative "jwt/jwk/akp"
|
|
16
|
+
require_relative "jwt/jwks"
|
|
17
|
+
|
|
18
|
+
module PQCrypto
|
|
19
|
+
module JWT
|
|
20
|
+
MLDSA44 = JWA::MLDSA44
|
|
21
|
+
MLDSA65 = JWA::MLDSA65
|
|
22
|
+
MLDSA87 = JWA::MLDSA87
|
|
23
|
+
|
|
24
|
+
SIGNING_ALGORITHMS = [JWA::MLDSA44, JWA::MLDSA65, JWA::MLDSA87].freeze
|
|
25
|
+
KEM_ALGORITHMS = [].freeze
|
|
26
|
+
ALGORITHMS = SIGNING_ALGORITHMS.freeze
|
|
27
|
+
ALGORITHMS_BY_JOSE = ALGORITHMS.to_h { |algorithm| [algorithm.alg, algorithm] }.freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def algorithms
|
|
31
|
+
ALGORITHMS
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def signing_algorithms
|
|
35
|
+
SIGNING_ALGORITHMS
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Kept as an explicit empty list so callers can branch safely.
|
|
39
|
+
# ML-KEM/JWE is intentionally not part of the first stable release.
|
|
40
|
+
def kem_algorithms
|
|
41
|
+
KEM_ALGORITHMS
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def algorithm_for(alg_string)
|
|
45
|
+
ALGORITHMS_BY_JOSE[alg_string]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def register!
|
|
49
|
+
registration_monitor.synchronize do
|
|
50
|
+
return true if registered?
|
|
51
|
+
|
|
52
|
+
SIGNING_ALGORITHMS.each { |algorithm| ::JWT::JWA.register_algorithm(algorithm) }
|
|
53
|
+
ensure_akp_jwk_registered!
|
|
54
|
+
@registered = true
|
|
55
|
+
end
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def registered?
|
|
60
|
+
@registered == true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def registration_monitor
|
|
66
|
+
@registration_monitor ||= Monitor.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ensure_akp_jwk_registered!
|
|
70
|
+
classes = ::JWT::JWK.classes
|
|
71
|
+
classes << ::JWT::JWK::AKP unless classes.include?(::JWT::JWK::AKP)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pq_crypto-jwt
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Roman Haydarov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: pq_crypto
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.4'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.4'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: jwt
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.1'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '4.0'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '3.1'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '4.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rake
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '13.0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '13.0'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: minitest
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '5.0'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '5.0'
|
|
74
|
+
description: Ruby-only adapter that adds post-quantum ML-DSA JWS signing and AKP JWK/JWKS
|
|
75
|
+
helpers to ruby-jwt, backed by pq_crypto.
|
|
76
|
+
email:
|
|
77
|
+
- romanhajdarov@gmail.com
|
|
78
|
+
executables: []
|
|
79
|
+
extensions: []
|
|
80
|
+
extra_rdoc_files: []
|
|
81
|
+
files:
|
|
82
|
+
- CHANGELOG.md
|
|
83
|
+
- LICENSE.txt
|
|
84
|
+
- README.md
|
|
85
|
+
- lib/pq_crypto/jwt.rb
|
|
86
|
+
- lib/pq_crypto/jwt/algorithms.rb
|
|
87
|
+
- lib/pq_crypto/jwt/algorithms/ml_dsa_44.rb
|
|
88
|
+
- lib/pq_crypto/jwt/algorithms/ml_dsa_65.rb
|
|
89
|
+
- lib/pq_crypto/jwt/algorithms/ml_dsa_87.rb
|
|
90
|
+
- lib/pq_crypto/jwt/errors.rb
|
|
91
|
+
- lib/pq_crypto/jwt/jwa.rb
|
|
92
|
+
- lib/pq_crypto/jwt/jwa/ml_dsa_44.rb
|
|
93
|
+
- lib/pq_crypto/jwt/jwa/ml_dsa_65.rb
|
|
94
|
+
- lib/pq_crypto/jwt/jwa/ml_dsa_87.rb
|
|
95
|
+
- lib/pq_crypto/jwt/jwa/ml_dsa_streaming.rb
|
|
96
|
+
- lib/pq_crypto/jwt/jwk.rb
|
|
97
|
+
- lib/pq_crypto/jwt/jwk/akp.rb
|
|
98
|
+
- lib/pq_crypto/jwt/jwks.rb
|
|
99
|
+
- lib/pq_crypto/jwt/keys.rb
|
|
100
|
+
- lib/pq_crypto/jwt/version.rb
|
|
101
|
+
homepage: https://github.com/roman-haidarov/pq_crypto-jwt
|
|
102
|
+
licenses:
|
|
103
|
+
- MIT
|
|
104
|
+
metadata: {}
|
|
105
|
+
rdoc_options: []
|
|
106
|
+
require_paths:
|
|
107
|
+
- lib
|
|
108
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: 3.4.0
|
|
113
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '0'
|
|
118
|
+
requirements: []
|
|
119
|
+
rubygems_version: 3.6.7
|
|
120
|
+
specification_version: 4
|
|
121
|
+
summary: ML-DSA JWS algorithms for ruby-jwt backed by pq_crypto
|
|
122
|
+
test_files: []
|