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 +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +75 -19
- data/lib/pq_crypto/jwt/errors.rb +1 -0
- data/lib/pq_crypto/jwt/jwa/ml_dsa_44.rb +2 -0
- data/lib/pq_crypto/jwt/jwa/ml_dsa_87.rb +2 -0
- data/lib/pq_crypto/jwt/jwa/ml_dsa_streaming.rb +108 -55
- data/lib/pq_crypto/jwt/jwa.rb +24 -55
- data/lib/pq_crypto/jwt/jwk/akp.rb +39 -43
- data/lib/pq_crypto/jwt/jwk.rb +118 -62
- data/lib/pq_crypto/jwt/jwks.rb +24 -23
- data/lib/pq_crypto/jwt/keys.rb +42 -56
- data/lib/pq_crypto/jwt/version.rb +1 -1
- data/lib/pq_crypto/jwt.rb +10 -36
- metadata +12 -10
- data/lib/pq_crypto/jwt/algorithms/ml_dsa_44.rb +0 -3
- data/lib/pq_crypto/jwt/algorithms/ml_dsa_65.rb +0 -3
- data/lib/pq_crypto/jwt/algorithms/ml_dsa_87.rb +0 -3
- data/lib/pq_crypto/jwt/algorithms.rb +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32f1a2663779d332f693d8e7fe38cfdb730ad67aba2303c178a116d75a0656a6
|
|
4
|
+
data.tar.gz: 13b3a76f35d0175700678dd15f6b030ec284c56092a47425a7a1dc5ef5a7e3e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- ML-DSA
|
|
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
|
|
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`
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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;
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
data/lib/pq_crypto/jwt/errors.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
+
STREAMING_ERRORS = [JSON::ParserError, ArgumentError, PQCrypto::Error, PQCrypto::JWT::Error, EncodingError].freeze
|
|
12
12
|
|
|
13
13
|
def streaming_supported?
|
|
14
|
-
pq_crypto_algorithm
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
26
|
+
header = normalize_signing_header!(header_fields).merge("alg" => alg)
|
|
26
27
|
encoded_header = base64url(JSON.generate(header))
|
|
27
|
-
|
|
28
|
-
signature = signing_key.sign_io(
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
48
|
+
return false unless supported_streaming_header?(header)
|
|
47
49
|
|
|
48
50
|
signature = Base64.urlsafe_decode64(encoded_signature)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
[
|
|
54
|
-
rescue
|
|
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!(
|
|
59
|
-
|
|
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
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
123
|
+
if length.nil?
|
|
124
|
+
drain_until_eof
|
|
125
|
+
return nil if @buffer.empty?
|
|
95
126
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
160
|
+
def consume_prefix
|
|
161
|
+
return if @prefix_done
|
|
128
162
|
|
|
163
|
+
@buffer << @prefix
|
|
129
164
|
@prefix_done = true
|
|
130
|
-
|
|
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
|
data/lib/pq_crypto/jwt/jwa.rb
CHANGED
|
@@ -6,40 +6,23 @@ require "pq_crypto"
|
|
|
6
6
|
module PQCrypto
|
|
7
7
|
module JWT
|
|
8
8
|
module JWA
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
return false unless
|
|
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
|
|
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
|
|
63
|
-
unless key.is_a?(
|
|
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}
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
58
|
+
rescue *VERIFY_ERRORS
|
|
59
|
+
false
|
|
91
60
|
end
|
|
92
61
|
end
|
|
93
62
|
end
|