pq_crypto-jwt 0.2.0 → 0.2.2
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 +37 -1
- data/README.md +34 -3
- data/lib/pq_crypto/jwt/jwa/ml_dsa_streaming.rb +74 -11
- data/lib/pq_crypto/jwt/jwk/akp.rb +12 -6
- data/lib/pq_crypto/jwt/jwk.rb +7 -2
- data/lib/pq_crypto/jwt/jwks.rb +133 -17
- data/lib/pq_crypto/jwt/version.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: 1e3f2795566f0f5c947da5064603b1307f6dabac8d3e3821091db761cf16b12e
|
|
4
|
+
data.tar.gz: 4866374dafdfcaa976922b77e62e2d73640ee5b0a8c83b2c48ff0f44142df925
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e16f7692d02822b00b5726935ae7f915bc59db28e79562ae7cc023284ca0d3369683244ddaa26045a7207e07e8e27f80796e06daa69dc1963bc0522d46cec7e
|
|
7
|
+
data.tar.gz: 1d6e9af3f6fe60bdd2b461eaaf01dc5f3d4980ede10a3498994691dbe23c38e01c6ab49b41eff65ac637e5bf05bb2342a20675ce00b366dc7979cb224845f188
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- CI workflow now matches the documented release matrix for Ruby `3.1`, `3.2`, `3.3`, `3.4`, `4.0` crossed with `jwt ~> 3.1.0` and `jwt ~> 3.2.0`.
|
|
8
|
+
- Renamed the synthetic RFC 9964 regression test file to `test_ml_dsa_sizes.rb`; it now describes size and canonical-thumbprint checks without implying full Appendix-vector coverage.
|
|
9
|
+
- `JWT::JWK::AKP` no longer expands and retains private seed material during initialization; private key materialization is lazy and happens only when `#secret_key` / `#signing_key` is requested.
|
|
10
|
+
- `JWKS.from_keys(kids:)` now raises `ArgumentError` when the number of kids does not match the number of public keys.
|
|
11
|
+
- JWKS lookup now ignores structurally invalid AKP JWK entries instead of returning malformed keys from weak filters.
|
|
12
|
+
- JWKS lookup now prefilters by `kid`/`alg` before reconstructing public keys and uses a bounded immutable validation cache for repeated lookups.
|
|
13
|
+
- `JWKS.from_keys(kids:)` now normalizes Array-compatible `kids` objects through `to_ary` before indexing.
|
|
14
|
+
- `JWK.thumbprint` now validates the AKP `pub` length for the declared ML-DSA algorithm before computing the digest.
|
|
15
|
+
- `DetachedSigningInputIO` now implements `#readpartial`, `#binmode`, `#binmode?`, and `#close` for better IO compatibility.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `PQCrypto::JWT::JWK.thumbprint_uri(jwk)` for RFC 9278-style JWK thumbprint URIs.
|
|
20
|
+
- `PQCrypto::JWT::JWKS.from_keys(..., kid_strategy: :thumbprint)` and `kid_strategy: :thumbprint_uri` for automatic key IDs.
|
|
21
|
+
- Optional `strict: true` mode for streaming detached JWS verification, raising `JWT::DecodeError` for structural/token errors and `JWT::VerificationError` for cryptographic verification failures.
|
|
22
|
+
- Thread-safe cache invalidation for `JWKS.loader`.
|
|
23
|
+
- Optional Node `jose` cross-implementation interop test for `ML-DSA-65`, wired as a dedicated CI job.
|
|
24
|
+
|
|
25
|
+
## 0.2.1
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Streaming detached JWS signing now rejects protected `b64` and `crit` headers instead of allowing semantic mismatch with the base64url-encoded signing input.
|
|
30
|
+
- Streaming detached JWS verification now fails closed for critical headers and `b64: false`.
|
|
31
|
+
- Streaming `chunk_size` is now validated as a positive integer before signing, verification, or detached signing-input reads.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Negative streaming tests for `crit`, `b64`, malformed base64url headers, and non-positive chunk sizes.
|
|
36
|
+
- Regression tests for ML-DSA public key/signature sizes and AKP JWK thumbprint canonical members.
|
|
37
|
+
- Explicit tests and documentation for trusted private AKP JWK import without `verify_public: true`.
|
|
38
|
+
|
|
3
39
|
## 0.2.0
|
|
4
40
|
|
|
5
41
|
### Changed
|
|
@@ -11,7 +47,7 @@
|
|
|
11
47
|
- PEM/DER loading now delegates to `PQCrypto::Key.from_pem` / `PQCrypto::Key.from_der` for auto-dispatch instead of manually parsing ASN.1 OIDs in the JWT adapter.
|
|
12
48
|
- `signature_length_valid?` now fails closed when signature metadata cannot be resolved.
|
|
13
49
|
- `JWT::JWK.classes` is no longer mutated at require-time; AKP registration happens through `PQCrypto::JWT.register!`.
|
|
14
|
-
-
|
|
50
|
+
- Documented the intended Ruby `3.1`, `3.2`, `3.3`, `3.4`, `4.0` and `jwt >= 3.1, < 4.0` compatibility target.
|
|
15
51
|
|
|
16
52
|
### Added
|
|
17
53
|
|
data/README.md
CHANGED
|
@@ -18,6 +18,8 @@ ML-KEM/JWE is **not included**. Full JWE support needs a separate standards-comp
|
|
|
18
18
|
- `pq_crypto` `~> 0.6.1`
|
|
19
19
|
- `jwt` `>= 3.1`, `< 4.0`
|
|
20
20
|
|
|
21
|
+
The CI release matrix covers Ruby `3.1`, `3.2`, `3.3`, `3.4`, `4.0` crossed with `jwt ~> 3.1.0` and `jwt ~> 3.2.0` on Linux and macOS. A dedicated Node `jose` interop job runs on Node 24 with `JOSE_INTEROP=1`.
|
|
22
|
+
|
|
21
23
|
`pq_crypto-jwt` is Ruby-only and does not ship its own native extension. Native ML-DSA work, seed-aware keys, PKCS#8/SPKI parsing, and streaming signing/verification are delegated to `pq_crypto`.
|
|
22
24
|
|
|
23
25
|
## Install
|
|
@@ -113,7 +115,7 @@ Private AKP JWK uses RFC 9964 seed format: `priv` is the 32 raw-byte ML-DSA seed
|
|
|
113
115
|
secret_key = PQCrypto::JWT::JWK.secret_key_from_jwk(private_jwk)
|
|
114
116
|
```
|
|
115
117
|
|
|
116
|
-
With `pq_crypto 0.6.1`, private import trusts the JWK `pub` field as metadata because the parent gem does not expose public seed derivation yet. Callers that need a strict `pub`/`priv` consistency check can request it explicitly; this will raise `UnsupportedFeature` until the parent exposes `Signature.public_key_from_seed` or `Signature.keypair_from_seed`:
|
|
118
|
+
With `pq_crypto 0.6.1`, private import trusts the JWK `pub` field as metadata because the parent gem does not expose public seed derivation yet. `secret_key_from_jwk` without `verify_public: true` is suitable only for trusted private JWK material. It does not prove that `pub` belongs to `priv`. Callers that need a strict `pub`/`priv` consistency check can request it explicitly; this will raise `UnsupportedFeature` until the parent exposes `Signature.public_key_from_seed` or `Signature.keypair_from_seed`:
|
|
117
119
|
|
|
118
120
|
```ruby
|
|
119
121
|
secret_key = PQCrypto::JWT::JWK.secret_key_from_jwk(private_jwk, verify_public: true)
|
|
@@ -129,6 +131,15 @@ jwk = PQCrypto::JWT::JWK.from_seed(seed_32_bytes, alg: "ML-DSA-65", public_key:
|
|
|
129
131
|
|
|
130
132
|
`from_secret_key` is reserved for a future parent release that exposes a public seed accessor. Expanded-only keys, and current `pq_crypto 0.6.1` keys, raise `UnsupportedFeature` instead of reading parent private state.
|
|
131
133
|
|
|
134
|
+
JWK thumbprints are available as raw RFC 7638 thumbprints or RFC 9278-style URIs:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
thumbprint = PQCrypto::JWT::JWK.thumbprint(jwk)
|
|
138
|
+
thumbprint_uri = PQCrypto::JWT::JWK.thumbprint_uri(jwk)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`thumbprint` validates that `pub` has the expected ML-DSA public-key length for the declared `alg` before hashing the canonical AKP members.
|
|
142
|
+
|
|
132
143
|
JWKS lookup with `ruby-jwt`:
|
|
133
144
|
|
|
134
145
|
```ruby
|
|
@@ -140,7 +151,14 @@ token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65", kid: "
|
|
|
140
151
|
payload, header = JWT.decode(token, nil, true, algorithms: ["ML-DSA-65"], jwks: jwks)
|
|
141
152
|
```
|
|
142
153
|
|
|
143
|
-
|
|
154
|
+
`JWKS.from_keys(kids:)` requires one `kid` per public key. For automatic key IDs, derive them from the AKP thumbprint:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
jwks = PQCrypto::JWT::JWKS.from_keys([keypair.public_key], kid_strategy: :thumbprint)
|
|
158
|
+
jwks = PQCrypto::JWT::JWKS.from_keys([keypair.public_key], kid_strategy: :thumbprint_uri)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
For rotation, pass `PQCrypto::JWT::JWKS.loader(callable_or_hash)` as the `jwks:` value. The loader is thread-safe and refreshes when `ruby-jwt` calls it with `invalidate: true`.
|
|
144
162
|
|
|
145
163
|
## Streaming detached JWS
|
|
146
164
|
|
|
@@ -173,6 +191,19 @@ PQCrypto::JWT::JWA::MLDSA87
|
|
|
173
191
|
|
|
174
192
|
`verify_io` returns `[payload_position, header]` on success. `payload_position` is `nil` for non-seekable streams that do not respond to `#pos`.
|
|
175
193
|
|
|
194
|
+
By default, streaming verification fails closed and returns `false` for malformed tokens or failed signatures. Use `strict: true` when callers need error classification: structural/token errors raise `JWT::DecodeError`, and cryptographic verification failures raise `JWT::VerificationError`.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
PQCrypto::JWT::JWA::MLDSA65.verify_io(
|
|
198
|
+
verification_key: keypair.public_key,
|
|
199
|
+
token: token,
|
|
200
|
+
payload_io: payload_io,
|
|
201
|
+
strict: true
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The streaming helper signs the regular compact JWS signing input with a base64url-encoded payload. It does not implement RFC 7797 unencoded payload mode. `sign_io` rejects protected `b64` and `crit` header fields, and `verify_io` fails closed for critical headers or `b64: false`. `chunk_size` must be a positive integer.
|
|
206
|
+
|
|
176
207
|
## Non-goals
|
|
177
208
|
|
|
178
209
|
This adapter deliberately does **not** expose:
|
|
@@ -194,7 +225,7 @@ JWK seed encoding; backed by pq_crypto, which should also be reviewed before
|
|
|
194
225
|
production use.
|
|
195
226
|
```
|
|
196
227
|
|
|
197
|
-
Use in production only after your own security review and interoperability testing.
|
|
228
|
+
Use in production only after your own security review and interoperability testing. The repository includes an optional Node `jose` interop test (`JOSE_INTEROP=1 bundle exec ruby test/test_jose_interop.rb`) for the ML-DSA-65 JWS/JWK path.
|
|
198
229
|
|
|
199
230
|
## License
|
|
200
231
|
|
|
@@ -8,7 +8,8 @@ module PQCrypto
|
|
|
8
8
|
module JWA
|
|
9
9
|
module MLDSAStreaming
|
|
10
10
|
DEFAULT_CHUNK_SIZE = 1 << 20
|
|
11
|
-
|
|
11
|
+
STREAMING_DECODE_ERRORS = [JSON::ParserError, ArgumentError, TypeError, EncodingError].freeze
|
|
12
|
+
STREAMING_VERIFY_ERRORS = [PQCrypto::Error, PQCrypto::JWT::Error].freeze
|
|
12
13
|
|
|
13
14
|
def streaming_supported?
|
|
14
15
|
PQCrypto::Signature.supported.include?(pq_crypto_algorithm)
|
|
@@ -19,10 +20,12 @@ module PQCrypto
|
|
|
19
20
|
def sign_io(signing_key:, payload_io: nil, io: nil, header_fields: {}, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
20
21
|
ensure_streaming!
|
|
21
22
|
ensure_key!(signing_key, PQCrypto::Signature::SecretKey, "signing")
|
|
23
|
+
validate_chunk_size!(chunk_size)
|
|
22
24
|
source = payload_io || io
|
|
23
25
|
raise ArgumentError, "payload_io must respond to #read" unless source.respond_to?(:read)
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
header = normalize_signing_header!(header_fields).merge("alg" => alg)
|
|
28
|
+
encoded_header = base64url(JSON.generate(header))
|
|
26
29
|
input = DetachedSigningInputIO.new(encoded_header, source, chunk_size: chunk_size)
|
|
27
30
|
signature = signing_key.sign_io(input, chunk_size: chunk_size, context: EMPTY_CONTEXT)
|
|
28
31
|
"#{encoded_header}..#{base64url(signature)}"
|
|
@@ -32,27 +35,32 @@ module PQCrypto
|
|
|
32
35
|
raise ::JWT::EncodeError, e.message
|
|
33
36
|
end
|
|
34
37
|
|
|
35
|
-
def verify_io(verification_key:, token:, payload_io:, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
38
|
+
def verify_io(verification_key:, token:, payload_io:, chunk_size: DEFAULT_CHUNK_SIZE, strict: false)
|
|
36
39
|
ensure_streaming!
|
|
37
40
|
ensure_key!(verification_key, PQCrypto::Signature::PublicKey, "verification")
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
validate_chunk_size!(chunk_size)
|
|
42
|
+
return streaming_decode_failure!(strict, "token must be a String") unless token.is_a?(String)
|
|
43
|
+
return streaming_decode_failure!(strict, "payload_io must respond to #read") unless payload_io.respond_to?(:read)
|
|
40
44
|
|
|
41
45
|
encoded_header, encoded_payload, encoded_signature, extra = token.split(".", -1)
|
|
42
|
-
|
|
46
|
+
unless extra.nil? && encoded_payload == "" && encoded_signature
|
|
47
|
+
return streaming_decode_failure!(strict, "compact detached JWS must have exactly three segments and an empty payload segment")
|
|
48
|
+
end
|
|
43
49
|
|
|
44
50
|
header = JSON.parse(Base64.urlsafe_decode64(encoded_header))
|
|
45
|
-
return
|
|
51
|
+
return streaming_decode_failure!(strict, "unsupported streaming JWS protected header") unless supported_streaming_header?(header)
|
|
46
52
|
|
|
47
53
|
signature = Base64.urlsafe_decode64(encoded_signature)
|
|
48
|
-
return
|
|
54
|
+
return streaming_verify_failure!(strict, "invalid #{alg} signature length") unless signature_length_valid?(signature)
|
|
49
55
|
|
|
50
56
|
input = DetachedSigningInputIO.new(encoded_header, payload_io, chunk_size: chunk_size)
|
|
51
|
-
return
|
|
57
|
+
return streaming_verify_failure!(strict, "Streaming JWS verification failed") unless verification_key.verify_io(input, signature, chunk_size: chunk_size, context: EMPTY_CONTEXT)
|
|
52
58
|
|
|
53
59
|
[payload_io.respond_to?(:pos) ? payload_io.pos : nil, header]
|
|
54
|
-
rescue *
|
|
55
|
-
|
|
60
|
+
rescue *STREAMING_DECODE_ERRORS => e
|
|
61
|
+
streaming_decode_failure!(strict, e.message)
|
|
62
|
+
rescue *STREAMING_VERIFY_ERRORS => e
|
|
63
|
+
streaming_verify_failure!(strict, e.message)
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
def verify_io!(**kwargs)
|
|
@@ -71,10 +79,54 @@ module PQCrypto
|
|
|
71
79
|
def base64url(bytes)
|
|
72
80
|
Base64.urlsafe_encode64(String(bytes).b, padding: false)
|
|
73
81
|
end
|
|
82
|
+
|
|
83
|
+
def normalize_signing_header!(header_fields)
|
|
84
|
+
unless header_fields.respond_to?(:to_hash)
|
|
85
|
+
raise ArgumentError, "header_fields must be a Hash-like object"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
header = header_fields.to_hash.each_with_object({}) { |(key, value), out| out[String(key)] = value }
|
|
89
|
+
unsupported = %w[b64 crit] & header.keys
|
|
90
|
+
unless unsupported.empty?
|
|
91
|
+
raise ArgumentError, "unsupported protected header#{unsupported.size == 1 ? '' : 's'}: #{unsupported.join(', ')}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
header
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def supported_streaming_header?(header)
|
|
98
|
+
return false unless header.is_a?(Hash) && header["alg"] == alg
|
|
99
|
+
return false if header.key?("crit")
|
|
100
|
+
return false if header.key?("b64") && header["b64"] != true
|
|
101
|
+
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_chunk_size!(chunk_size)
|
|
106
|
+
return if chunk_size.is_a?(Integer) && chunk_size.positive?
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "chunk_size must be a positive Integer"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def streaming_decode_failure!(strict, message)
|
|
112
|
+
raise ::JWT::DecodeError, message if strict
|
|
113
|
+
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def streaming_verify_failure!(strict, message)
|
|
118
|
+
raise ::JWT::VerificationError, message if strict
|
|
119
|
+
|
|
120
|
+
false
|
|
121
|
+
end
|
|
74
122
|
end
|
|
75
123
|
|
|
76
124
|
class DetachedSigningInputIO
|
|
77
125
|
def initialize(encoded_header, payload_io, chunk_size: MLDSAStreaming::DEFAULT_CHUNK_SIZE)
|
|
126
|
+
unless chunk_size.is_a?(Integer) && chunk_size.positive?
|
|
127
|
+
raise ArgumentError, "chunk_size must be a positive Integer"
|
|
128
|
+
end
|
|
129
|
+
|
|
78
130
|
@prefix = "#{encoded_header}.".b
|
|
79
131
|
@payload_io = payload_io
|
|
80
132
|
@chunk_size = chunk_size
|
|
@@ -105,6 +157,17 @@ module PQCrypto
|
|
|
105
157
|
replace_outbuf(outbuf, result)
|
|
106
158
|
end
|
|
107
159
|
|
|
160
|
+
def readpartial(maxlen, outbuf = nil)
|
|
161
|
+
result = read(maxlen, outbuf)
|
|
162
|
+
raise EOFError if result.nil? || result.empty?
|
|
163
|
+
|
|
164
|
+
result
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def binmode = self
|
|
168
|
+
def binmode? = true
|
|
169
|
+
def close = nil
|
|
170
|
+
|
|
108
171
|
private
|
|
109
172
|
|
|
110
173
|
def replace_outbuf(outbuf, result)
|
|
@@ -19,7 +19,7 @@ module JWT
|
|
|
19
19
|
params = params.is_a?(String) ? { kid: params } : (params || {})
|
|
20
20
|
params = params.transform_keys(&:to_sym)
|
|
21
21
|
key_params = extract_key_params(key)
|
|
22
|
-
@checked_public_key
|
|
22
|
+
@checked_public_key = check_jwk_params!(key_params, params)
|
|
23
23
|
super({ kid_generator: NullKidGenerator }.merge(options || {}), key_params.merge(params))
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -35,7 +35,7 @@ module JWT
|
|
|
35
35
|
def secret_key
|
|
36
36
|
raise JWT::JWKError, "AKP JWK does not contain private material" unless private?
|
|
37
37
|
|
|
38
|
-
@secret_key ||=
|
|
38
|
+
@secret_key ||= PQCrypto::JWT::JWK.secret_key_from_jwk(string_export(include_private: true))
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def export(options = {})
|
|
@@ -80,6 +80,14 @@ module JWT
|
|
|
80
80
|
raise JWT::JWKError, e.message
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
def validate_private_seed_metadata!(jwk)
|
|
84
|
+
seed = PQCrypto::JWT::JWK.base64url_decode(jwk.fetch("priv") { raise PQCrypto::JWT::Error, "JWK priv is required" })
|
|
85
|
+
return if seed.bytesize == PQCrypto::JWT::JWK::SEED_BYTES
|
|
86
|
+
|
|
87
|
+
raise PQCrypto::JWT::Error,
|
|
88
|
+
"Invalid priv seed length for #{jwk.fetch('alg').inspect}: expected #{PQCrypto::JWT::JWK::SEED_BYTES}, got #{seed.bytesize}"
|
|
89
|
+
end
|
|
90
|
+
|
|
83
91
|
def check_jwk_params!(key_params, params)
|
|
84
92
|
raise ArgumentError, "cannot overwrite cryptographic key attributes" unless (AKP_KEY_ELEMENTS & params.keys).empty?
|
|
85
93
|
raise JWT::JWKError, "Incorrect 'kty' value: #{key_params[:kty]}, expected #{KTY}" unless key_params[:kty] == KTY
|
|
@@ -88,10 +96,8 @@ module JWT
|
|
|
88
96
|
raise JWT::JWKError, "Unsupported AKP JWK alg: #{key_params[:alg].inspect}" unless PQCrypto::JWT.algorithm_for(key_params[:alg])
|
|
89
97
|
|
|
90
98
|
parsed = key_params.transform_keys(&:to_s)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
key_params[:priv] ? PQCrypto::JWT::JWK.secret_key_from_jwk(parsed) : nil
|
|
94
|
-
]
|
|
99
|
+
validate_private_seed_metadata!(parsed) if key_params.key?(:priv)
|
|
100
|
+
PQCrypto::JWT::JWK.public_key_from_jwk(parsed)
|
|
95
101
|
rescue PQCrypto::JWT::Error => e
|
|
96
102
|
raise JWT::JWKError, e.message
|
|
97
103
|
end
|
data/lib/pq_crypto/jwt/jwk.rb
CHANGED
|
@@ -11,6 +11,7 @@ module PQCrypto
|
|
|
11
11
|
|
|
12
12
|
KTY = "AKP".freeze
|
|
13
13
|
SEED_BYTES = defined?(PQCrypto::PKCS8::ML_DSA_SEED_BYTES) ? PQCrypto::PKCS8::ML_DSA_SEED_BYTES : 32
|
|
14
|
+
THUMBPRINT_URI_PREFIX = "urn:ietf:params:oauth:jwk-thumbprint:sha-256:".freeze
|
|
14
15
|
|
|
15
16
|
def from_public_key(public_key, kid: nil, use: nil, key_ops: nil)
|
|
16
17
|
validate_key!(public_key, PQCrypto::Signature::PublicKey)
|
|
@@ -72,13 +73,17 @@ module PQCrypto
|
|
|
72
73
|
|
|
73
74
|
def thumbprint(jwk_hash)
|
|
74
75
|
jwk = normalize_hash!(jwk_hash)
|
|
75
|
-
algorithm_from_jwk!(jwk)
|
|
76
|
-
|
|
76
|
+
algorithm = algorithm_from_jwk!(jwk)
|
|
77
|
+
decode_field!(jwk, "pub", algorithm)
|
|
77
78
|
|
|
78
79
|
canonical = JSON.generate("alg" => jwk.fetch("alg"), "kty" => KTY, "pub" => jwk.fetch("pub"))
|
|
79
80
|
base64url(Digest::SHA256.digest(canonical.b))
|
|
80
81
|
end
|
|
81
82
|
|
|
83
|
+
def thumbprint_uri(jwk_hash)
|
|
84
|
+
"#{THUMBPRINT_URI_PREFIX}#{thumbprint(jwk_hash)}"
|
|
85
|
+
end
|
|
86
|
+
|
|
82
87
|
def base64url(bytes)
|
|
83
88
|
Base64.urlsafe_encode64(String(bytes).b, padding: false)
|
|
84
89
|
end
|
data/lib/pq_crypto/jwt/jwks.rb
CHANGED
|
@@ -6,50 +6,166 @@ module PQCrypto
|
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
8
|
CACHE_EMPTY = Object.new.freeze
|
|
9
|
+
KID_STRATEGIES = %i[thumbprint thumbprint_uri].freeze
|
|
10
|
+
VALIDATION_CACHE_LIMIT = 1024
|
|
11
|
+
VALIDATION_CACHE_FIELD_BYTES_LIMIT = 8192
|
|
12
|
+
VALIDATION_CACHE = {}
|
|
13
|
+
VALIDATION_CACHE_MUTEX = Mutex.new
|
|
14
|
+
private_constant :VALIDATION_CACHE_FIELD_BYTES_LIMIT, :VALIDATION_CACHE, :VALIDATION_CACHE_MUTEX
|
|
9
15
|
|
|
10
|
-
def from_keys(public_keys, kids: nil)
|
|
11
|
-
keys = Array(public_keys)
|
|
12
|
-
|
|
16
|
+
def from_keys(public_keys, kids: nil, kid_strategy: nil)
|
|
17
|
+
keys = Array(public_keys)
|
|
18
|
+
kids = normalize_kids!(keys, kids, kid_strategy)
|
|
19
|
+
|
|
20
|
+
jwks = keys.each_with_index.map do |public_key, index|
|
|
21
|
+
jwk = PQCrypto::JWT::JWK.from_public_key(public_key, kid: kids&.fetch(index))
|
|
22
|
+
apply_kid_strategy(jwk, kid_strategy)
|
|
13
23
|
end
|
|
14
|
-
{ "keys" =>
|
|
24
|
+
{ "keys" => jwks }.freeze
|
|
15
25
|
end
|
|
16
26
|
|
|
17
27
|
def find(jwks, kid: nil, alg: nil, thumbprint: nil)
|
|
18
|
-
|
|
28
|
+
each_candidate(jwks, kid, alg, thumbprint) do |key|
|
|
29
|
+
return key
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
19
32
|
end
|
|
20
33
|
|
|
21
34
|
def find_all(jwks, kid: nil, alg: nil, thumbprint: nil)
|
|
22
|
-
|
|
35
|
+
matches = []
|
|
36
|
+
each_candidate(jwks, kid, alg, thumbprint) { |key| matches << key }
|
|
37
|
+
matches
|
|
23
38
|
end
|
|
24
39
|
|
|
25
40
|
def loader(jwks_hash_or_callable)
|
|
26
41
|
cached = CACHE_EMPTY
|
|
42
|
+
mutex = Mutex.new
|
|
43
|
+
|
|
27
44
|
lambda do |options = {}|
|
|
28
45
|
options ||= {}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
invalidate = options[:invalidate]
|
|
47
|
+
current = cached
|
|
48
|
+
return current if !invalidate && !current.equal?(CACHE_EMPTY)
|
|
49
|
+
|
|
50
|
+
mutex.synchronize do
|
|
51
|
+
cached = CACHE_EMPTY if invalidate
|
|
52
|
+
if cached.equal?(CACHE_EMPTY)
|
|
53
|
+
cached = jwks_hash_or_callable.respond_to?(:call) ? jwks_hash_or_callable.call(options) : jwks_hash_or_callable
|
|
54
|
+
end
|
|
55
|
+
cached
|
|
32
56
|
end
|
|
33
|
-
cached
|
|
34
57
|
end
|
|
35
58
|
end
|
|
36
59
|
|
|
37
|
-
def
|
|
60
|
+
def raw_keys_from(jwks)
|
|
38
61
|
source = jwks.respond_to?(:to_hash) ? jwks.to_hash : jwks
|
|
39
62
|
keys = source.respond_to?(:[]) ? (source["keys"] || source[:keys]) : nil
|
|
40
|
-
Array(keys)
|
|
63
|
+
Array(keys)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :raw_keys_from
|
|
66
|
+
|
|
67
|
+
def each_candidate(jwks, kid, alg, thumbprint)
|
|
68
|
+
raw_keys_from(jwks).each do |key|
|
|
69
|
+
next unless key.respond_to?(:to_hash)
|
|
70
|
+
|
|
71
|
+
normalized = normalize_jwk_hash(key.to_hash)
|
|
72
|
+
next unless cheap_match?(normalized, kid, alg)
|
|
73
|
+
next unless cached_valid_public_jwk?(normalized)
|
|
74
|
+
next unless thumbprint.nil? || PQCrypto::JWT::JWK.thumbprint(normalized) == thumbprint
|
|
75
|
+
|
|
76
|
+
yield normalized
|
|
77
|
+
rescue PQCrypto::JWT::Error, ArgumentError, TypeError
|
|
78
|
+
next
|
|
79
|
+
end
|
|
41
80
|
end
|
|
42
|
-
private_class_method :
|
|
81
|
+
private_class_method :each_candidate
|
|
82
|
+
|
|
83
|
+
def normalize_jwk_hash(hash)
|
|
84
|
+
hash.each_with_object({}) { |(key, value), out| out[String(key)] = value }
|
|
85
|
+
end
|
|
86
|
+
private_class_method :normalize_jwk_hash
|
|
43
87
|
|
|
44
88
|
def value_for(hash, key) = hash[key] || hash[key.to_sym]
|
|
45
89
|
private_class_method :value_for
|
|
46
90
|
|
|
47
|
-
def
|
|
91
|
+
def cheap_match?(key, kid, alg)
|
|
48
92
|
(kid.nil? || value_for(key, "kid") == kid) &&
|
|
49
|
-
(alg.nil? || value_for(key, "alg") == alg)
|
|
50
|
-
|
|
93
|
+
(alg.nil? || value_for(key, "alg") == alg)
|
|
94
|
+
end
|
|
95
|
+
private_class_method :cheap_match?
|
|
96
|
+
|
|
97
|
+
def normalize_kids!(public_keys, kids, kid_strategy)
|
|
98
|
+
raise ArgumentError, "kid_strategy must be one of #{KID_STRATEGIES.inspect}" if kid_strategy && !KID_STRATEGIES.include?(kid_strategy)
|
|
99
|
+
raise ArgumentError, "kids and kid_strategy are mutually exclusive" if kids && kid_strategy
|
|
100
|
+
return nil unless kids
|
|
101
|
+
|
|
102
|
+
unless kids.respond_to?(:to_ary)
|
|
103
|
+
raise ArgumentError, "kids must be an Array with one kid per public key"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
normalized = kids.to_ary
|
|
107
|
+
unless normalized.is_a?(Array)
|
|
108
|
+
raise ArgumentError, "kids must be an Array with one kid per public key"
|
|
109
|
+
end
|
|
110
|
+
raise ArgumentError, "kids.size must match public_keys.size" unless normalized.size == public_keys.size
|
|
111
|
+
|
|
112
|
+
normalized
|
|
113
|
+
end
|
|
114
|
+
private_class_method :normalize_kids!
|
|
115
|
+
|
|
116
|
+
def apply_kid_strategy(jwk, kid_strategy)
|
|
117
|
+
return jwk unless kid_strategy
|
|
118
|
+
|
|
119
|
+
kid = case kid_strategy
|
|
120
|
+
when :thumbprint then PQCrypto::JWT::JWK.thumbprint(jwk)
|
|
121
|
+
when :thumbprint_uri then PQCrypto::JWT::JWK.thumbprint_uri(jwk)
|
|
122
|
+
end
|
|
123
|
+
jwk.merge("kid" => kid).freeze
|
|
124
|
+
end
|
|
125
|
+
private_class_method :apply_kid_strategy
|
|
126
|
+
|
|
127
|
+
def cached_valid_public_jwk?(key)
|
|
128
|
+
cache_key = validation_cache_key(key)
|
|
129
|
+
return valid_public_jwk?(key) unless cache_key
|
|
130
|
+
|
|
131
|
+
VALIDATION_CACHE_MUTEX.synchronize do
|
|
132
|
+
cached = VALIDATION_CACHE[cache_key]
|
|
133
|
+
return cached unless cached.nil?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
result = valid_public_jwk?(key)
|
|
137
|
+
VALIDATION_CACHE_MUTEX.synchronize do
|
|
138
|
+
VALIDATION_CACHE.shift if VALIDATION_CACHE.size >= VALIDATION_CACHE_LIMIT
|
|
139
|
+
VALIDATION_CACHE[cache_key] = result
|
|
140
|
+
end
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
private_class_method :cached_valid_public_jwk?
|
|
144
|
+
|
|
145
|
+
def validation_cache_key(key)
|
|
146
|
+
kty = key["kty"]
|
|
147
|
+
alg = key["alg"]
|
|
148
|
+
pub = key["pub"]
|
|
149
|
+
return nil unless kty.is_a?(String) && alg.is_a?(String) && pub.is_a?(String)
|
|
150
|
+
return nil if kty.bytesize > VALIDATION_CACHE_FIELD_BYTES_LIMIT
|
|
151
|
+
return nil if alg.bytesize > VALIDATION_CACHE_FIELD_BYTES_LIMIT
|
|
152
|
+
return nil if pub.bytesize > VALIDATION_CACHE_FIELD_BYTES_LIMIT
|
|
153
|
+
|
|
154
|
+
[kty.dup.freeze, alg.dup.freeze, pub.dup.freeze].freeze
|
|
155
|
+
end
|
|
156
|
+
private_class_method :validation_cache_key
|
|
157
|
+
|
|
158
|
+
def valid_public_jwk?(key)
|
|
159
|
+
PQCrypto::JWT::JWK.public_key_from_jwk(key)
|
|
160
|
+
true
|
|
161
|
+
rescue PQCrypto::JWT::Error, ArgumentError, TypeError
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
private_class_method :valid_public_jwk?
|
|
165
|
+
|
|
166
|
+
def clear_validation_cache!
|
|
167
|
+
VALIDATION_CACHE_MUTEX.synchronize { VALIDATION_CACHE.clear }
|
|
51
168
|
end
|
|
52
|
-
private_class_method :match?
|
|
53
169
|
end
|
|
54
170
|
end
|
|
55
171
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pq_crypto-jwt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Haydarov
|
|
@@ -100,7 +100,7 @@ licenses:
|
|
|
100
100
|
- MIT
|
|
101
101
|
metadata:
|
|
102
102
|
source_code_uri: https://github.com/roman-haidarov/pq_crypto-jwt
|
|
103
|
-
changelog_uri: https://github.com/roman-haidarov/pq_crypto-jwt/blob/v0.2.
|
|
103
|
+
changelog_uri: https://github.com/roman-haidarov/pq_crypto-jwt/blob/v0.2.2/CHANGELOG.md
|
|
104
104
|
bug_tracker_uri: https://github.com/roman-haidarov/pq_crypto-jwt/issues
|
|
105
105
|
post_install_message:
|
|
106
106
|
rdoc_options: []
|