pq_crypto-jwt 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54ccde27793860cab079549cb2ce297730f52869b23e9fe98b1dc99bd5aba6c3
4
- data.tar.gz: 913b9fc2cb37b6f952e2b4251a8a4bca07c9258b829607dd87c6e209e6dbf41f
3
+ metadata.gz: 32f1a2663779d332f693d8e7fe38cfdb730ad67aba2303c178a116d75a0656a6
4
+ data.tar.gz: 13b3a76f35d0175700678dd15f6b030ec284c56092a47425a7a1dc5ef5a7e3e5
5
5
  SHA512:
6
- metadata.gz: b14e7d94add26152c0b9e3234ea3696dba72ea12bf9433d65ecd3a204fe86f3e56bea9dea063609f7742ad3dd8593cb6f1b966b071a3ad59762792d4a9180027
7
- data.tar.gz: c78f588b5fb61a9a928debde3e7fad160e37dc0240a3addd8cc47998e2ab11bf231418a6caac5c5339baac38729c9e42f766cf7f5f976dcad611bb4f9d4cda92
6
+ metadata.gz: 53333891ef2330c3e871916102ec6d08f8d7753c8b79f405b26f1f142562f5d6a91cb319aabb62499924b7bcd488a37a21f7a30390a250c356dd12ce83d14cb9
7
+ data.tar.gz: 16679d31fb72e7ea1d0563e52cbb12ada1f31ed3e8e3554ead19a81a0f3f62a9336c03ba0b43370fba7280706f7bca7d1bddc3038b739959c4a114cc4f755513
data/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Fixed
6
+
7
+ - Streaming detached JWS signing now rejects protected `b64` and `crit` headers instead of allowing semantic mismatch with the base64url-encoded signing input.
8
+ - Streaming detached JWS verification now fails closed for critical headers and `b64: false`.
9
+ - Streaming `chunk_size` is now validated as a positive integer before signing, verification, or detached signing-input reads.
10
+ - 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`.
11
+
12
+ ### Added
13
+
14
+ - Negative streaming tests for `crit`, `b64`, malformed base64url headers, and non-positive chunk sizes.
15
+ - RFC 9964 regression tests for ML-DSA public key/signature sizes and AKP JWK thumbprint canonical members.
16
+ - Explicit tests and documentation for trusted private AKP JWK import without `verify_public: true`.
17
+
18
+ ## 0.2.0
19
+
20
+ ### Changed
21
+
22
+ - Retargeted the adapter to the current parent line: `pq_crypto ~> 0.6.1`.
23
+ - Updated documentation and release language from draft-era ML-DSA JOSE wording to RFC 9964 terminology.
24
+ - Explicitly pass the RFC 9964 empty ML-DSA context in all one-shot and streaming sign/verify paths.
25
+ - Streaming detached JWS now applies to `ML-DSA-44`, `ML-DSA-65`, and `ML-DSA-87` instead of only `ML-DSA-65`.
26
+ - 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.
27
+ - `signature_length_valid?` now fails closed when signature metadata cannot be resolved.
28
+ - `JWT::JWK.classes` is no longer mutated at require-time; AKP registration happens through `PQCrypto::JWT.register!`.
29
+ - 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`.
30
+
31
+ ### Added
32
+
33
+ - `PQCrypto::JWT::UnsupportedFeature` for release-scope or parent-API-gated functionality.
34
+ - Seed-based private AKP JWK import via `PQCrypto::JWT::JWK.secret_key_from_jwk`.
35
+ - 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.
36
+ - 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.
37
+ - 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.
38
+ - Optional public JWK metadata on export: `use:` and `key_ops:`.
39
+ - DER helper methods:
40
+ - `PQCrypto::JWT::Keys.public_from_der`
41
+ - `PQCrypto::JWT::Keys.secret_from_der`
42
+ - JWKS lookup helpers for JWK thumbprint fallback and multi-match rotation windows:
43
+ - `PQCrypto::JWT::JWKS.find(..., thumbprint:)`
44
+ - `PQCrypto::JWT::JWKS.find_all`
45
+ - 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.
46
+
47
+ ### Fixed
48
+
49
+ - Removed private parent-state reads from private AKP JWK export; the adapter no longer uses parent instance variables as compatibility API.
50
+ - `DetachedSigningInputIO#read(nil)` now follows the Ruby IO contract and reads to EOF.
51
+ - Streaming verification now rejects non-object decoded JOSE headers before consulting `alg`.
52
+ - Streaming verification now rejects compact tokens with extra segments.
53
+ - One-shot verification rescues only expected verification/input/encoding errors instead of masking all `StandardError`.
54
+ - `JWT::JWK::AKP` no longer advertises automatic dispatch for `SecretKey`/`Keypair` until parent seed-export APIs exist.
55
+ - Removed a redundant unreachable private-JWK parameter check from `JWT::JWK::AKP`.
56
+ - JWKS loader caching no longer relies on `||=`, so falsey JWKS values do not cause repeated reloads.
57
+
58
+ ### Deferred
59
+
60
+ - Composite / hybrid JOSE signatures.
61
+ - ML-KEM / JWE support.
62
+ - RBS / Sorbet signatures.
63
+ - Macro refactor for the three ML-DSA modules.
64
+
3
65
  ## 0.1.2
4
66
 
5
67
  ### Changed
data/README.md CHANGED
@@ -2,27 +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.
13
14
 
14
15
  ## Requirements
15
16
 
16
17
  - Ruby `>= 3.1.0`
17
- - `pq_crypto` `>= 0.5.4`, `< 0.6`
18
+ - `pq_crypto` `~> 0.6.1`
18
19
  - `jwt` `>= 3.1`, `< 4.0`
19
20
 
20
- `pq_crypto-jwt` is Ruby-only and does not ship its own native extension. Native ML-DSA work is delegated to `pq_crypto`; Ruby 3.4+ keeps the optimized `pq_crypto` path, while Ruby 3.1-3.3 use the compatibility path provided by `pq_crypto` 0.5.3+.
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`.
21
22
 
22
23
  ## Install
23
24
 
24
25
  ```ruby
25
- gem "pq_crypto-jwt", "~> 0.1"
26
+ gem "pq_crypto-jwt", "~> 0.2"
26
27
  ```
27
28
 
28
29
  ## Register the algorithms
@@ -55,9 +56,11 @@ token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65")
55
56
  payload, header = JWT.decode(token, keypair.public_key, true, algorithm: "ML-DSA-65")
56
57
  ```
57
58
 
58
- 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`.
59
60
 
60
- ## PEM import
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`.
62
+
63
+ ## PEM and DER import
61
64
 
62
65
  SPKI public keys and PKCS#8 secret keys can be imported through the helper API:
63
66
 
@@ -76,6 +79,13 @@ public_key = PQCrypto::JWT::Keys.public_from_pem(spki_pem, expect: :signature)
76
79
  secret_key = PQCrypto::JWT::Keys.secret_from_pem(pkcs8_pem, expect: :signature)
77
80
  ```
78
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
+
79
89
  ## JWK and JWKS
80
90
 
81
91
  Public AKP JWK round-trip:
@@ -86,6 +96,39 @@ jwk = PQCrypto::JWT::JWK.from_public_key(keypair.public_key, kid: "signing-key")
86
96
  public_key = PQCrypto::JWT::JWK.public_key_from_jwk(jwk)
87
97
  ```
88
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. `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
+
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
+
89
132
  JWKS lookup with `ruby-jwt`:
90
133
 
91
134
  ```ruby
@@ -97,11 +140,11 @@ token = JWT.encode({ "sub" => "alice" }, keypair.secret_key, "ML-DSA-65", kid: "
97
140
  payload, header = JWT.decode(token, nil, true, algorithms: ["ML-DSA-65"], jwks: jwks)
98
141
  ```
99
142
 
100
- 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`.
101
144
 
102
145
  ## Streaming detached JWS
103
146
 
104
- `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.
105
148
 
106
149
  ```ruby
107
150
  File.open("payload.bin", "rb") do |payload_io|
@@ -120,24 +163,37 @@ File.open("payload.bin", "rb") do |payload_io|
120
163
  end
121
164
  ```
122
165
 
123
- ## 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
+ 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.
177
+
178
+ ## Non-goals
124
179
 
125
- The first release deliberately does **not** expose:
180
+ This adapter deliberately does **not** expose:
126
181
 
127
182
  - ML-KEM JWE key agreement
128
183
  - JWE compact or JSON serialization
129
184
  - JWE content encryption, AAD, IV, or authentication tag handling
130
- - private AKP JWK import/export; use PEM/PKCS#8 for signing keys
185
+ - composite / hybrid JOSE signatures
186
+ - custom ML-DSA JOSE `ctx` values
131
187
  - general-purpose JWT claims policy beyond what `ruby-jwt` already provides
132
188
 
133
- This keeps the public API small and avoids publishing draft-incompatible JWE behavior.
189
+ This keeps the public API small and avoids publishing speculative JWE or composite-signature behavior before the relevant JOSE work is stable.
134
190
 
135
191
  ## Security status
136
192
 
137
193
  ```text
138
- unaudited; tracks draft-ietf-cose-dilithium for ML-DSA JOSE identifiers;
139
- identifiers and wire formats may change before RFC publication; backed by
140
- pq_crypto, which should also be reviewed before production use.
194
+ unaudited; implements the RFC 9964 ML-DSA JOSE algorithm identifiers and AKP
195
+ JWK seed encoding; backed by pq_crypto, which should also be reviewed before
196
+ production use.
141
197
  ```
142
198
 
143
199
  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,25 @@ 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
+ validate_chunk_size!(chunk_size)
22
23
  source = payload_io || io
23
24
  raise ArgumentError, "payload_io must respond to #read" unless source.respond_to?(:read)
24
25
 
25
- header = stringify_keys(header_fields || {}).merge("alg" => alg)
26
+ header = normalize_signing_header!(header_fields).merge("alg" => alg)
26
27
  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)
28
+ input = DetachedSigningInputIO.new(encoded_header, source, chunk_size: chunk_size)
29
+ signature = signing_key.sign_io(input, chunk_size: chunk_size, context: EMPTY_CONTEXT)
29
30
  "#{encoded_header}..#{base64url(signature)}"
30
31
  rescue PQCrypto::JWT::Error, ArgumentError
31
32
  raise
@@ -34,51 +35,81 @@ module PQCrypto
34
35
  end
35
36
 
36
37
  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)
38
+ ensure_streaming!
39
+ ensure_key!(verification_key, PQCrypto::Signature::PublicKey, "verification")
40
+ validate_chunk_size!(chunk_size)
39
41
  raise ArgumentError, "token must be a String" unless token.is_a?(String)
40
42
  raise ArgumentError, "payload_io must respond to #read" unless payload_io.respond_to?(:read)
41
43
 
42
- encoded_header, encoded_payload, encoded_signature = token.split(".", -1)
43
- return false unless encoded_header && encoded_payload == "" && encoded_signature
44
+ encoded_header, encoded_payload, encoded_signature, extra = token.split(".", -1)
45
+ return false unless extra.nil? && encoded_payload == "" && encoded_signature
44
46
 
45
47
  header = JSON.parse(Base64.urlsafe_decode64(encoded_header))
46
- return false unless header["alg"] == alg
48
+ return false unless supported_streaming_header?(header)
47
49
 
48
50
  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
51
+ return false unless signature_length_valid?(signature)
52
+
53
+ input = DetachedSigningInputIO.new(encoded_header, payload_io, chunk_size: chunk_size)
54
+ return false unless verification_key.verify_io(input, signature, chunk_size: chunk_size, context: EMPTY_CONTEXT)
52
55
 
53
- [payload_position(payload_io), header]
54
- rescue JSON::ParserError, ArgumentError, PQCrypto::InvalidKeyError
56
+ [payload_io.respond_to?(:pos) ? payload_io.pos : nil, header]
57
+ rescue *STREAMING_ERRORS
55
58
  false
56
59
  end
57
60
 
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
-
61
+ def verify_io!(**kwargs)
62
+ verify_io(**kwargs) || raise(::JWT::VerificationError, "Streaming JWS verification failed")
62
63
  true
63
64
  end
64
65
 
65
66
  private
66
67
 
68
+ def ensure_streaming!
69
+ return if streaming_supported?
70
+
71
+ raise PQCrypto::JWT::UnsupportedFeature, "#{alg} does not support streaming JWS"
72
+ end
73
+
67
74
  def base64url(bytes)
68
75
  Base64.urlsafe_encode64(String(bytes).b, padding: false)
69
76
  end
70
77
 
71
- def stringify_keys(hash)
72
- hash.each_with_object({}) { |(key, value), out| out[String(key)] = value }
78
+ def normalize_signing_header!(header_fields)
79
+ unless header_fields.respond_to?(:to_hash)
80
+ raise ArgumentError, "header_fields must be a Hash-like object"
81
+ end
82
+
83
+ header = header_fields.to_hash.each_with_object({}) { |(key, value), out| out[String(key)] = value }
84
+ unsupported = %w[b64 crit] & header.keys
85
+ unless unsupported.empty?
86
+ raise ArgumentError, "unsupported protected header#{unsupported.size == 1 ? '' : 's'}: #{unsupported.join(', ')}"
87
+ end
88
+
89
+ header
90
+ end
91
+
92
+ def supported_streaming_header?(header)
93
+ return false unless header.is_a?(Hash) && header["alg"] == alg
94
+ return false if header.key?("crit")
95
+ return false if header.key?("b64") && header["b64"] != true
96
+
97
+ true
73
98
  end
74
99
 
75
- def payload_position(payload_io)
76
- payload_io.respond_to?(:pos) ? payload_io.pos : nil
100
+ def validate_chunk_size!(chunk_size)
101
+ return if chunk_size.is_a?(Integer) && chunk_size.positive?
102
+
103
+ raise ArgumentError, "chunk_size must be a positive Integer"
77
104
  end
78
105
  end
79
106
 
80
107
  class DetachedSigningInputIO
81
108
  def initialize(encoded_header, payload_io, chunk_size: MLDSAStreaming::DEFAULT_CHUNK_SIZE)
109
+ unless chunk_size.is_a?(Integer) && chunk_size.positive?
110
+ raise ArgumentError, "chunk_size must be a positive Integer"
111
+ end
112
+
82
113
  @prefix = "#{encoded_header}.".b
83
114
  @payload_io = payload_io
84
115
  @chunk_size = chunk_size
@@ -89,45 +120,67 @@ module PQCrypto
89
120
  end
90
121
 
91
122
  def read(length = nil, outbuf = nil)
92
- length ||= @chunk_size
93
- fill(length)
94
- return nil if @buffer.empty?
123
+ if length.nil?
124
+ drain_until_eof
125
+ return nil if @buffer.empty?
95
126
 
96
- result = @buffer.byteslice(0, length)
97
- @buffer = @buffer.byteslice(result.bytesize..-1) || +""
127
+ result = @buffer
128
+ @buffer = +""
129
+ else
130
+ raise ArgumentError, "negative length #{length} given" if length.negative?
131
+ return replace_outbuf(outbuf, +"") if length.zero?
132
+
133
+ fill(length)
134
+ return nil if @buffer.empty?
135
+
136
+ result = @buffer.byteslice(0, length)
137
+ @buffer = @buffer.byteslice(result.bytesize..-1) || +""
138
+ end
139
+
140
+ replace_outbuf(outbuf, result)
141
+ end
142
+
143
+ private
144
+
145
+ def replace_outbuf(outbuf, result)
98
146
  outbuf&.replace(result)
99
147
  outbuf || result
100
148
  end
101
149
 
102
- private
150
+ def drain_until_eof
151
+ consume_prefix
152
+ feed_one_chunk until @payload_done
153
+ end
103
154
 
104
155
  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
156
+ consume_prefix
157
+ feed_one_chunk while @buffer.bytesize < length && !@payload_done
124
158
  end
125
159
 
126
- def consume_prefix?
127
- return true if @prefix_done
160
+ def consume_prefix
161
+ return if @prefix_done
128
162
 
163
+ @buffer << @prefix
129
164
  @prefix_done = true
130
- false
165
+ end
166
+
167
+ def feed_one_chunk
168
+ chunk = @payload_io.read(@chunk_size)
169
+ if chunk.nil? || chunk.empty?
170
+ @buffer << Base64.urlsafe_encode64(@carry, padding: false) unless @carry.empty?
171
+ @carry = +""
172
+ @payload_done = true
173
+ return
174
+ end
175
+
176
+ bytes = @carry + chunk.b
177
+ aligned = bytes.bytesize - (bytes.bytesize % 3)
178
+ if aligned.positive?
179
+ @buffer << Base64.urlsafe_encode64(bytes.byteslice(0, aligned), padding: false)
180
+ @carry = bytes.byteslice(aligned..-1) || +""
181
+ else
182
+ @carry = bytes
183
+ end
131
184
  end
132
185
  end
133
186
  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