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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ded8bedea5417580ef9a3199f47543c037c1506dfe2e7a8db86bb6c1f2f94c7a
4
- data.tar.gz: 716ed896e2f4960dcd3ca0afbd6ef94a43e45e63ea7f7284c1d277bfb2366418
3
+ metadata.gz: 1e3f2795566f0f5c947da5064603b1307f6dabac8d3e3821091db761cf16b12e
4
+ data.tar.gz: 4866374dafdfcaa976922b77e62e2d73640ee5b0a8c83b2c48ff0f44142df925
5
5
  SHA512:
6
- metadata.gz: 371849705f4387e78f3c98d08982274ad072bf519f2699c6d1df7de5254fe70e01c1e3a8d2c3d54cd2fe1573666c9fc871431726089a5907c586f6dbdfe0a7c0
7
- data.tar.gz: 8e408633d2862da41b4e546ad717153a8312e7983698058aeb53da5435523fff670ee1fe4ed2d9cb392b9aed7f655737046122aa9e2e09efe3455eea9f63d941
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
- - CI matrix now covers Ruby `3.1`, `3.2`, `3.3`, `3.4`, `4.0` crossed with `jwt ~> 3.1.0` and `jwt ~> 3.2.0`.
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
- For rotation, pass `PQCrypto::JWT::JWKS.loader(callable_or_hash)` as the `jwks:` value. The loader refreshes when `ruby-jwt` calls it with `invalidate: true`.
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
- STREAMING_ERRORS = [JSON::ParserError, ArgumentError, PQCrypto::Error, PQCrypto::JWT::Error, EncodingError].freeze
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
- encoded_header = base64url(JSON.generate(header_fields.transform_keys(&:to_s).merge("alg" => alg)))
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
- raise ArgumentError, "token must be a String" unless token.is_a?(String)
39
- raise ArgumentError, "payload_io must respond to #read" unless payload_io.respond_to?(:read)
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
- return false unless extra.nil? && encoded_payload == "" && encoded_signature
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 false unless header.is_a?(Hash) && header["alg"] == alg
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 false unless signature_length_valid?(signature)
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 false unless verification_key.verify_io(input, signature, chunk_size: chunk_size, context: EMPTY_CONTEXT)
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 *STREAMING_ERRORS
55
- false
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, @checked_secret_key = check_jwk_params!(key_params, params)
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 ||= @checked_secret_key || PQCrypto::JWT::JWK.secret_key_from_jwk(string_export(include_private: true))
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
- PQCrypto::JWT::JWK.public_key_from_jwk(parsed),
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
@@ -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
- raise PQCrypto::JWT::Error, "JWK pub is required" unless jwk.key?("pub")
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
@@ -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).each_with_index.map do |public_key, index|
12
- PQCrypto::JWT::JWK.from_public_key(public_key, kid: kids&.fetch(index, nil))
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" => keys }.freeze
24
+ { "keys" => jwks }.freeze
15
25
  end
16
26
 
17
27
  def find(jwks, kid: nil, alg: nil, thumbprint: nil)
18
- keys_from(jwks).find { |key| match?(key, kid, alg, thumbprint) }
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
- keys_from(jwks).select { |key| match?(key, kid, alg, thumbprint) }
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
- cached = CACHE_EMPTY if options[:invalidate]
30
- if cached.equal?(CACHE_EMPTY)
31
- cached = jwks_hash_or_callable.respond_to?(:call) ? jwks_hash_or_callable.call(options) : jwks_hash_or_callable
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 keys_from(jwks)
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).map { |key| key.to_hash.each_with_object({}) { |(k, v), out| out[String(k)] = v } }
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 :keys_from
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 match?(key, kid, alg, thumbprint)
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
- (thumbprint.nil? || PQCrypto::JWT::JWK.thumbprint(key) == thumbprint)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module PQCrypto
4
4
  module JWT
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  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.0
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.0/CHANGELOG.md
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: []