pq_crypto-jwt 0.1.1 → 0.2.0

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: 4f383c2f1f0414e4817f80ea6865dc4c9d7acdb512d33e1d3e368b7091f84e9c
4
- data.tar.gz: 00ed418f44277af5ec23ae1ffb4be8edf909cf40e6d974cafd072fd2fdbd0c4f
3
+ metadata.gz: ded8bedea5417580ef9a3199f47543c037c1506dfe2e7a8db86bb6c1f2f94c7a
4
+ data.tar.gz: 716ed896e2f4960dcd3ca0afbd6ef94a43e45e63ea7f7284c1d277bfb2366418
5
5
  SHA512:
6
- metadata.gz: a1f3e7e7bea52901a570378619485d0d7981129534738e30972187d02c35aa7061bc644c5b6c91ef006a640c9d483993e2d2382447c3b2e8a7205a241b15e922
7
- data.tar.gz: c3cc3d4ef8e8b6e73b25cc17bd700f2acfe32b66114cca0b73ee983647856b794824bced8727360769577bbd83e3cc458ce374c2ab00de7886155cbbca46221f
6
+ metadata.gz: 371849705f4387e78f3c98d08982274ad072bf519f2699c6d1df7de5254fe70e01c1e3a8d2c3d54cd2fe1573666c9fc871431726089a5907c586f6dbdfe0a7c0
7
+ data.tar.gz: 8e408633d2862da41b4e546ad717153a8312e7983698058aeb53da5435523fff670ee1fe4ed2d9cb392b9aed7f655737046122aa9e2e09efe3455eea9f63d941
data/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Changed
6
+
7
+ - Retargeted the adapter to the current parent line: `pq_crypto ~> 0.6.1`.
8
+ - Updated documentation and release language from draft-era ML-DSA JOSE wording to RFC 9964 terminology.
9
+ - Explicitly pass the RFC 9964 empty ML-DSA context in all one-shot and streaming sign/verify paths.
10
+ - Streaming detached JWS now applies to `ML-DSA-44`, `ML-DSA-65`, and `ML-DSA-87` instead of only `ML-DSA-65`.
11
+ - 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
+ - `signature_length_valid?` now fails closed when signature metadata cannot be resolved.
13
+ - `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`.
15
+
16
+ ### Added
17
+
18
+ - `PQCrypto::JWT::UnsupportedFeature` for release-scope or parent-API-gated functionality.
19
+ - Seed-based private AKP JWK import via `PQCrypto::JWT::JWK.secret_key_from_jwk`.
20
+ - Optional strict private JWK `pub`/`priv` consistency verification via `verify_public: true` for both import and explicit seed export; this is gated on future parent public seed-derivation APIs.
21
+ - Explicit seed-based private AKP JWK export via `PQCrypto::JWT::JWK.from_seed(..., public_key:)`, with optional `verify_public: true` consistency checking when parent support exists.
22
+ - Reserved `PQCrypto::JWT::JWK.from_secret_key` for future seed-exporting parent keys; current `pq_crypto 0.6.1` keys raise `UnsupportedFeature` instead of reading private parent state.
23
+ - Optional public JWK metadata on export: `use:` and `key_ops:`.
24
+ - DER helper methods:
25
+ - `PQCrypto::JWT::Keys.public_from_der`
26
+ - `PQCrypto::JWT::Keys.secret_from_der`
27
+ - JWKS lookup helpers for JWK thumbprint fallback and multi-match rotation windows:
28
+ - `PQCrypto::JWT::JWKS.find(..., thumbprint:)`
29
+ - `PQCrypto::JWT::JWKS.find_all`
30
+ - Negative tests for malformed streaming headers, extra compact segments, invalid private seed lengths, strict private JWK verification gating, dependency constraints, private API constraints, and read contract behavior.
31
+
32
+ ### Fixed
33
+
34
+ - Removed private parent-state reads from private AKP JWK export; the adapter no longer uses parent instance variables as compatibility API.
35
+ - `DetachedSigningInputIO#read(nil)` now follows the Ruby IO contract and reads to EOF.
36
+ - Streaming verification now rejects non-object decoded JOSE headers before consulting `alg`.
37
+ - Streaming verification now rejects compact tokens with extra segments.
38
+ - One-shot verification rescues only expected verification/input/encoding errors instead of masking all `StandardError`.
39
+ - `JWT::JWK::AKP` no longer advertises automatic dispatch for `SecretKey`/`Keypair` until parent seed-export APIs exist.
40
+ - Removed a redundant unreachable private-JWK parameter check from `JWT::JWK::AKP`.
41
+ - JWKS loader caching no longer relies on `||=`, so falsey JWKS values do not cause repeated reloads.
42
+
43
+ ### Deferred
44
+
45
+ - Composite / hybrid JOSE signatures.
46
+ - ML-KEM / JWE support.
47
+ - RBS / Sorbet signatures.
48
+ - Macro refactor for the three ML-DSA modules.
49
+
50
+ ## 0.1.2
51
+
52
+ ### Changed
53
+
54
+ - Lowered the supported Ruby floor from `>= 3.4.0` to `>= 3.1.0`, matching the `pq_crypto` 0.5.x compatibility line.
55
+ - Updated the runtime dependency to `pq_crypto >= 0.5.3, < 0.6` so Ruby 3.1-3.3 use the compatibility path from the core gem while Ruby 3.4+ keeps the optimized path.
56
+ - Expanded CI coverage to Ruby 3.1, 3.2, 3.3, 3.4, and 4.0 on Linux and macOS.
57
+ - Added hard-constraint tests for the Ruby floor and `pq_crypto` dependency range.
58
+
3
59
  ## Initial public release
4
60
 
5
61
  ### Included
data/README.md CHANGED
@@ -2,19 +2,28 @@
2
2
 
3
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
4
 
5
- The first public release intentionally focuses on one stable surface:
5
+ This release focuses on the RFC 9964 ML-DSA JWS surface:
6
6
 
7
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
8
+ - AKP JWK/JWKS helpers for ML-DSA verification keys
9
+ - seed-based private AKP JWK import and explicit seed export paths
10
+ - PEM/DER import helpers for ML-DSA SPKI/PKCS#8 keys through `PQCrypto::Key`
11
+ - streaming detached JWS helpers for `ML-DSA-44`, `ML-DSA-65`, and `ML-DSA-87`
11
12
 
12
- ML-KEM/JWE is **not included** in this first release. Full JWE support needs a separate standards-compatible implementation and interoperability tests.
13
+ ML-KEM/JWE is **not included**. Full JWE support needs a separate standards-compatible implementation and interoperability tests.
14
+
15
+ ## Requirements
16
+
17
+ - Ruby `>= 3.1.0`
18
+ - `pq_crypto` `~> 0.6.1`
19
+ - `jwt` `>= 3.1`, `< 4.0`
20
+
21
+ `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`.
13
22
 
14
23
  ## Install
15
24
 
16
25
  ```ruby
17
- gem "pq_crypto-jwt", "~> 0.1"
26
+ gem "pq_crypto-jwt", "~> 0.2"
18
27
  ```
19
28
 
20
29
  ## Register the algorithms
@@ -47,9 +56,11 @@ token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65")
47
56
  payload, header = JWT.decode(token, keypair.public_key, true, algorithm: "ML-DSA-65")
48
57
  ```
49
58
 
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`.
59
+ 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`.
60
+
61
+ For RFC 9964 JOSE algorithms, the FIPS 204 `ctx` value is fixed to the empty string. The adapter passes `context: "".b` explicitly in both one-shot and streaming paths and does not expose a per-token context option for `ML-DSA-44`, `ML-DSA-65`, or `ML-DSA-87`.
51
62
 
52
- ## PEM import
63
+ ## PEM and DER import
53
64
 
54
65
  SPKI public keys and PKCS#8 secret keys can be imported through the helper API:
55
66
 
@@ -68,6 +79,13 @@ public_key = PQCrypto::JWT::Keys.public_from_pem(spki_pem, expect: :signature)
68
79
  secret_key = PQCrypto::JWT::Keys.secret_from_pem(pkcs8_pem, expect: :signature)
69
80
  ```
70
81
 
82
+ DER helpers are also available:
83
+
84
+ ```ruby
85
+ public_key = PQCrypto::JWT::Keys.public_from_der(spki_der)
86
+ secret_key = PQCrypto::JWT::Keys.secret_from_der(pkcs8_der, passphrase: passphrase)
87
+ ```
88
+
71
89
  ## JWK and JWKS
72
90
 
73
91
  Public AKP JWK round-trip:
@@ -78,6 +96,39 @@ jwk = PQCrypto::JWT::JWK.from_public_key(keypair.public_key, kid: "signing-key")
78
96
  public_key = PQCrypto::JWT::JWK.public_key_from_jwk(jwk)
79
97
  ```
80
98
 
99
+ Optional public JWK metadata can be supplied directly:
100
+
101
+ ```ruby
102
+ jwk = PQCrypto::JWT::JWK.from_public_key(
103
+ keypair.public_key,
104
+ kid: "signing-key",
105
+ use: "sig",
106
+ key_ops: ["verify"]
107
+ )
108
+ ```
109
+
110
+ Private AKP JWK uses RFC 9964 seed format: `priv` is the 32 raw-byte ML-DSA seed encoded as base64url. It is not the expanded ML-DSA secret key, and helper APIs expect raw seed bytes, not hex or text encodings.
111
+
112
+ ```ruby
113
+ secret_key = PQCrypto::JWT::JWK.secret_key_from_jwk(private_jwk)
114
+ ```
115
+
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`:
117
+
118
+ ```ruby
119
+ secret_key = PQCrypto::JWT::JWK.secret_key_from_jwk(private_jwk, verify_public: true)
120
+ ```
121
+
122
+ Export is intentionally explicit. Current `pq_crypto 0.6.1` can import seed-aware keys but does not expose a stable public seed accessor / public-key-from-seed API, so use `from_seed` and pass the matching public key:
123
+
124
+ ```ruby
125
+ jwk = PQCrypto::JWT::JWK.from_seed(seed_32_bytes, alg: "ML-DSA-65", public_key: public_key)
126
+ ```
127
+
128
+ `from_seed` also accepts `verify_public: true` for symmetry with private import. On current `pq_crypto 0.6.1` this raises `UnsupportedFeature`; once the parent exposes public seed derivation, it will reject a `public_key:` that does not match the supplied seed. Without `verify_public: true`, the explicitly supplied `public_key:` is trusted.
129
+
130
+ `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
+
81
132
  JWKS lookup with `ruby-jwt`:
82
133
 
83
134
  ```ruby
@@ -89,11 +140,11 @@ token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65", kid: "
89
140
  payload, header = JWT.decode(token, nil, true, algorithms: ["ML-DSA-65"], jwks: jwks)
90
141
  ```
91
142
 
92
- For rotation, pass `PQCrypto::JWT::JWKS.loader(callable_or_hash)` as the `jwks:` value.
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`.
93
144
 
94
145
  ## Streaming detached JWS
95
146
 
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.
147
+ All three ML-DSA JOSE algorithms support the streaming detached JWS helper when backed by `pq_crypto ~> 0.6.1`. The compact form is `header..signature`; callers must supply the same payload stream separately for verification.
97
148
 
98
149
  ```ruby
99
150
  File.open("payload.bin", "rb") do |payload_io|
@@ -112,24 +163,35 @@ File.open("payload.bin", "rb") do |payload_io|
112
163
  end
113
164
  ```
114
165
 
115
- ## Non-goals for the first release
166
+ Equivalent helpers exist on:
167
+
168
+ ```text
169
+ PQCrypto::JWT::JWA::MLDSA44
170
+ PQCrypto::JWT::JWA::MLDSA65
171
+ PQCrypto::JWT::JWA::MLDSA87
172
+ ```
173
+
174
+ `verify_io` returns `[payload_position, header]` on success. `payload_position` is `nil` for non-seekable streams that do not respond to `#pos`.
175
+
176
+ ## Non-goals
116
177
 
117
- The first release deliberately does **not** expose:
178
+ This adapter deliberately does **not** expose:
118
179
 
119
180
  - ML-KEM JWE key agreement
120
181
  - JWE compact or JSON serialization
121
182
  - JWE content encryption, AAD, IV, or authentication tag handling
122
- - private AKP JWK import/export; use PEM/PKCS#8 for signing keys
183
+ - composite / hybrid JOSE signatures
184
+ - custom ML-DSA JOSE `ctx` values
123
185
  - general-purpose JWT claims policy beyond what `ruby-jwt` already provides
124
186
 
125
- This keeps the public API small and avoids publishing draft-incompatible JWE behavior.
187
+ This keeps the public API small and avoids publishing speculative JWE or composite-signature behavior before the relevant JOSE work is stable.
126
188
 
127
189
  ## Security status
128
190
 
129
191
  ```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.
192
+ unaudited; implements the RFC 9964 ML-DSA JOSE algorithm identifiers and AKP
193
+ JWK seed encoding; backed by pq_crypto, which should also be reviewed before
194
+ production use.
133
195
  ```
134
196
 
135
197
  Use in production only after your own security review and interoperability testing.
@@ -4,6 +4,7 @@ module PQCrypto
4
4
  module JWT
5
5
  class Error < StandardError; end
6
6
  class UnsupportedAlgorithm < Error; end
7
+ class UnsupportedFeature < Error; end
7
8
  class KeyTypeError < Error; end
8
9
  end
9
10
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../jwa"
4
+ require_relative "ml_dsa_streaming"
4
5
 
5
6
  module PQCrypto
6
7
  module JWT
@@ -8,6 +9,7 @@ module PQCrypto
8
9
  module MLDSA44
9
10
  extend ::JWT::JWA::SigningAlgorithm
10
11
  extend PQCrypto::JWT::JWA::MLDSA
12
+ extend PQCrypto::JWT::JWA::MLDSAStreaming
11
13
 
12
14
  ALG = "ML-DSA-44".freeze
13
15
  PQ_CRYPTO_ALGORITHM = :ml_dsa_44
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../jwa"
4
+ require_relative "ml_dsa_streaming"
4
5
 
5
6
  module PQCrypto
6
7
  module JWT
@@ -8,6 +9,7 @@ module PQCrypto
8
9
  module MLDSA87
9
10
  extend ::JWT::JWA::SigningAlgorithm
10
11
  extend PQCrypto::JWT::JWA::MLDSA
12
+ extend PQCrypto::JWT::JWA::MLDSAStreaming
11
13
 
12
14
  ALG = "ML-DSA-87".freeze
13
15
  PQ_CRYPTO_ALGORITHM = :ml_dsa_87
@@ -8,24 +8,23 @@ module PQCrypto
8
8
  module JWA
9
9
  module MLDSAStreaming
10
10
  DEFAULT_CHUNK_SIZE = 1 << 20
11
- EMPTY_CONTEXT = "".b.freeze
11
+ STREAMING_ERRORS = [JSON::ParserError, ArgumentError, PQCrypto::Error, PQCrypto::JWT::Error, EncodingError].freeze
12
12
 
13
13
  def streaming_supported?
14
- pq_crypto_algorithm == :ml_dsa_65 &&
15
- PQCrypto::Signature.supported.include?(:ml_dsa_65)
14
+ PQCrypto::Signature.supported.include?(pq_crypto_algorithm)
15
+ rescue PQCrypto::Error
16
+ false
16
17
  end
17
18
 
18
19
  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)
20
+ ensure_streaming!
21
+ ensure_key!(signing_key, PQCrypto::Signature::SecretKey, "signing")
22
22
  source = payload_io || io
23
23
  raise ArgumentError, "payload_io must respond to #read" unless source.respond_to?(:read)
24
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)
25
+ encoded_header = base64url(JSON.generate(header_fields.transform_keys(&:to_s).merge("alg" => alg)))
26
+ input = DetachedSigningInputIO.new(encoded_header, source, chunk_size: chunk_size)
27
+ signature = signing_key.sign_io(input, chunk_size: chunk_size, context: EMPTY_CONTEXT)
29
28
  "#{encoded_header}..#{base64url(signature)}"
30
29
  rescue PQCrypto::JWT::Error, ArgumentError
31
30
  raise
@@ -34,46 +33,43 @@ module PQCrypto
34
33
  end
35
34
 
36
35
  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)
36
+ ensure_streaming!
37
+ ensure_key!(verification_key, PQCrypto::Signature::PublicKey, "verification")
39
38
  raise ArgumentError, "token must be a String" unless token.is_a?(String)
40
39
  raise ArgumentError, "payload_io must respond to #read" unless payload_io.respond_to?(:read)
41
40
 
42
- encoded_header, encoded_payload, encoded_signature = token.split(".", -1)
43
- return false unless encoded_header && encoded_payload == "" && encoded_signature
41
+ encoded_header, encoded_payload, encoded_signature, extra = token.split(".", -1)
42
+ return false unless extra.nil? && encoded_payload == "" && encoded_signature
44
43
 
45
44
  header = JSON.parse(Base64.urlsafe_decode64(encoded_header))
46
- return false unless header["alg"] == alg
45
+ return false unless header.is_a?(Hash) && header["alg"] == alg
47
46
 
48
47
  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
48
+ return false unless signature_length_valid?(signature)
52
49
 
53
- [payload_position(payload_io), header]
54
- rescue JSON::ParserError, ArgumentError, PQCrypto::InvalidKeyError
50
+ 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)
52
+
53
+ [payload_io.respond_to?(:pos) ? payload_io.pos : nil, header]
54
+ rescue *STREAMING_ERRORS
55
55
  false
56
56
  end
57
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
-
58
+ def verify_io!(**kwargs)
59
+ verify_io(**kwargs) || raise(::JWT::VerificationError, "Streaming JWS verification failed")
62
60
  true
63
61
  end
64
62
 
65
63
  private
66
64
 
67
- def base64url(bytes)
68
- Base64.urlsafe_encode64(String(bytes).b, padding: false)
69
- end
65
+ def ensure_streaming!
66
+ return if streaming_supported?
70
67
 
71
- def stringify_keys(hash)
72
- hash.each_with_object({}) { |(key, value), out| out[String(key)] = value }
68
+ raise PQCrypto::JWT::UnsupportedFeature, "#{alg} does not support streaming JWS"
73
69
  end
74
70
 
75
- def payload_position(payload_io)
76
- payload_io.respond_to?(:pos) ? payload_io.pos : nil
71
+ def base64url(bytes)
72
+ Base64.urlsafe_encode64(String(bytes).b, padding: false)
77
73
  end
78
74
  end
79
75
 
@@ -89,45 +85,67 @@ module PQCrypto
89
85
  end
90
86
 
91
87
  def read(length = nil, outbuf = nil)
92
- length ||= @chunk_size
93
- fill(length)
94
- return nil if @buffer.empty?
88
+ if length.nil?
89
+ drain_until_eof
90
+ return nil if @buffer.empty?
91
+
92
+ result = @buffer
93
+ @buffer = +""
94
+ else
95
+ raise ArgumentError, "negative length #{length} given" if length.negative?
96
+ return replace_outbuf(outbuf, +"") if length.zero?
97
+
98
+ fill(length)
99
+ return nil if @buffer.empty?
100
+
101
+ result = @buffer.byteslice(0, length)
102
+ @buffer = @buffer.byteslice(result.bytesize..-1) || +""
103
+ end
95
104
 
96
- result = @buffer.byteslice(0, length)
97
- @buffer = @buffer.byteslice(result.bytesize..-1) || +""
105
+ replace_outbuf(outbuf, result)
106
+ end
107
+
108
+ private
109
+
110
+ def replace_outbuf(outbuf, result)
98
111
  outbuf&.replace(result)
99
112
  outbuf || result
100
113
  end
101
114
 
102
- private
115
+ def drain_until_eof
116
+ consume_prefix
117
+ feed_one_chunk until @payload_done
118
+ end
103
119
 
104
120
  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
121
+ consume_prefix
122
+ feed_one_chunk while @buffer.bytesize < length && !@payload_done
124
123
  end
125
124
 
126
- def consume_prefix?
127
- return true if @prefix_done
125
+ def consume_prefix
126
+ return if @prefix_done
128
127
 
128
+ @buffer << @prefix
129
129
  @prefix_done = true
130
- false
130
+ end
131
+
132
+ def feed_one_chunk
133
+ chunk = @payload_io.read(@chunk_size)
134
+ if chunk.nil? || chunk.empty?
135
+ @buffer << Base64.urlsafe_encode64(@carry, padding: false) unless @carry.empty?
136
+ @carry = +""
137
+ @payload_done = true
138
+ return
139
+ end
140
+
141
+ bytes = @carry + chunk.b
142
+ aligned = bytes.bytesize - (bytes.bytesize % 3)
143
+ if aligned.positive?
144
+ @buffer << Base64.urlsafe_encode64(bytes.byteslice(0, aligned), padding: false)
145
+ @carry = bytes.byteslice(aligned..-1) || +""
146
+ else
147
+ @carry = bytes
148
+ end
131
149
  end
132
150
  end
133
151
  end
@@ -6,40 +6,23 @@ require "pq_crypto"
6
6
  module PQCrypto
7
7
  module JWT
8
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
9
+ EMPTY_CONTEXT = "".b.freeze
10
+ VERIFY_ERRORS = [
11
+ PQCrypto::Error, PQCrypto::JWT::Error,
12
+ ::JWT::DecodeError, ::JWT::VerificationError, ::JWT::JWKError,
13
+ ArgumentError, TypeError, EncodingError
14
+ ].freeze
29
15
 
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
16
+ module MLDSA
17
+ def valid_alg?(alg_to_validate) = alg_to_validate == alg
18
+ def pq_crypto_algorithm = self::PQ_CRYPTO_ALGORITHM
19
+ def key_kind = :signature
37
20
 
38
21
  def sign(data:, signing_key:)
39
- ensure_secret_key!(signing_key)
22
+ ensure_key!(signing_key, PQCrypto::Signature::SecretKey, "signing")
40
23
  raise ArgumentError, "data must be a String" unless data.is_a?(String)
41
24
 
42
- signing_key.sign(data.b)
25
+ signing_key.sign(data.b, context: EMPTY_CONTEXT)
43
26
  rescue PQCrypto::JWT::KeyTypeError, ArgumentError
44
27
  raise
45
28
  rescue StandardError => e
@@ -47,47 +30,33 @@ module PQCrypto
47
30
  end
48
31
 
49
32
  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)
33
+ return false unless verification_key.is_a?(PQCrypto::Signature::PublicKey)
34
+ return false unless verification_key.algorithm == pq_crypto_algorithm
35
+ return false unless data.is_a?(String) && signature.is_a?(String)
53
36
  return false unless signature_length_valid?(signature)
54
37
 
55
- verification_key.verify(data.b, signature.b)
56
- rescue PQCrypto::InvalidKeyError, PQCrypto::JWT::Error, ArgumentError
38
+ verification_key.verify(data.b, signature.b, context: EMPTY_CONTEXT)
39
+ rescue *VERIFY_ERRORS
57
40
  false
58
41
  end
59
42
 
60
43
  private
61
44
 
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"
45
+ def ensure_key!(key, klass, role)
46
+ unless key.is_a?(klass)
47
+ raise PQCrypto::JWT::KeyTypeError, "#{alg} #{role} requires #{klass}"
66
48
  end
67
-
68
49
  return if key.algorithm == pq_crypto_algorithm
69
50
 
70
51
  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
52
+ "#{alg} #{role} requires #{pq_crypto_algorithm.inspect} key, got #{key.algorithm.inspect}"
83
53
  end
84
54
 
85
55
  def signature_length_valid?(signature)
86
- details = PQCrypto::Signature.details(pq_crypto_algorithm)
87
- expected = details[:signature_bytes] || details["signature_bytes"]
56
+ expected = PQCrypto::Signature.details(pq_crypto_algorithm)[:signature_bytes]
88
57
  expected.nil? || signature.bytesize == expected
89
- rescue StandardError
90
- true
58
+ rescue *VERIFY_ERRORS
59
+ false
91
60
  end
92
61
  end
93
62
  end