sd_jwt 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 15befa7b04cc0779c3a130c585c3f493ad0271c213a2022296038f6762749c0a
4
+ data.tar.gz: 27fae66b5f1c27040b5dd19bf5d5370d42f002b1feaaafc3fabb8b2d297b8b82
5
+ SHA512:
6
+ metadata.gz: 5d573a2a3934b2e987c50167397a4db4a8637b13caf8793251d666b8dff2310dcedda3d4fe33cc371bb3cd47292500a3f6381f7caa40b299cb0eb9245bf32fe4
7
+ data.tar.gz: 11d3e8fc6c8f31fa03a8867819833eb18fa42369a09492523d58759050f59606df68113bdc5cd5e1b41e1171fcf5cd768b4608ceb1710b7cd69b72b189c63083
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-12
4
+
5
+ Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # SDJWT
2
+
3
+ Issue, selectively present, and verify [RFC 9901](https://www.rfc-editor.org/rfc/rfc9901.html) SD-JWTs in Ruby. Signatures use [ruby-jwt](https://github.com/jwt/ruby-jwt) and OpenSSL; this gem implements disclosures, recursive claim processing, and holder key binding.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ gem "sd_jwt", "~> 0.1.0"
9
+ ```
10
+
11
+ Ruby 3.3 or later, JRuby, and TruffleRuby are tested in CI. Runtime dependencies are `json` (2.19.4 or later, including 3.x) and `jwt` (3.3.x). No other gem in the JSON formats family is required.
12
+
13
+ ## Issue and verify
14
+
15
+ ```ruby
16
+ require "sd_jwt"
17
+
18
+ issuer_key = OpenSSL::PKey::EC.generate("prime256v1")
19
+ holder_key = OpenSSL::PKey::EC.generate("prime256v1")
20
+ holder_jwk = JWT::JWK.new(holder_key).export.transform_keys(&:to_s)
21
+
22
+ issuer = SDJWT::Issuer.new(signing_key: issuer_key, alg: "ES256")
23
+ credential = issuer.issue(
24
+ { "iss" => "https://issuer.example", "exp" => Time.now.to_i + 3600,
25
+ "name" => "Example", "address" => { "street" => "Harbor Street", "city" => "Port" } },
26
+ disclose: ["/name", "/address", "/address/street"],
27
+ decoys: 2,
28
+ holder_jwk: holder_jwk,
29
+ header: { "typ" => "example+sd-jwt", "kid" => "key-1" }
30
+ )
31
+
32
+ held = SDJWT.parse(credential.to_s) # Unauthenticated holder-side parsing.
33
+ presentation = held.present(
34
+ only: ["/name"],
35
+ key_binding: { key: holder_key, alg: "ES256", aud: "https://verifier.example", nonce: "transaction-nonce" }
36
+ )
37
+
38
+ verifier = SDJWT::Verifier.new(
39
+ algorithms: ["ES256"],
40
+ issuer_key: issuer_key, # Or ->(untrusted_header, untrusted_payload) { trusted_key_lookup(...) }
41
+ issuer: "https://issuer.example",
42
+ required_claims: ["iss", "exp", "cnf"],
43
+ require_key_binding: true,
44
+ audience: "https://verifier.example",
45
+ nonce: "transaction-nonce",
46
+ max_kb_age: 300
47
+ )
48
+ result = verifier.verify(presentation)
49
+ result.claims # Immutable, authenticated disclosed claims.
50
+ result.key_binding_verified # true
51
+ ```
52
+
53
+ Keys in claim and header hashes must be strings. Values must be JSON data. Generation rejects reserved `_sd`, `_sd_alg`, and `...` members. `SdJwt` is a compatibility alias for `SDJWT`.
54
+
55
+ Use `token.disclosed_paths` to enumerate available disclosure paths. `present(only:)` automatically includes disclosed parents needed by a selected child. Selecting a parent includes its directly visible contents, but does not select separately hidden children. Array indices refer to the original issuer payload: hidden array entries disappear from the verified result. Selecting no paths sends no disclosures. Creating a new presentation removes an existing KB-JWT; pass `key_binding:` to bind the new presentation.
56
+
57
+ `decoys:` adds that many digests to each object `_sd` array, including arrays within parent disclosures. It does not add dummy array elements. Digest arrays are sorted. Salts contain 128 random bits. The total number of issued disclosures and decoys is bounded by `max_disclosures`.
58
+
59
+ ## Supported scope
60
+
61
+ | Feature | Support |
62
+ | --- | --- |
63
+ | RFC 9901 Compact, Flattened JSON, General JSON | Read, write, present, verify |
64
+ | Object claims, array entries, recursive disclosure | Supported |
65
+ | Unknown application claims and header parameters | Preserved |
66
+ | Hashes | `sha-256` default; `sha-384` and `sha-512` require verifier opt-in |
67
+ | Issuer signatures | ES256/384/512, RS256/384/512, PS256/384/512; HS256/384/512 only when explicitly configured |
68
+ | Holder key binding | `cnf.jwk` with public EC or RSA keys, KB-JWT type/time/audience/nonce/presentation digest |
69
+ | JSON duplicate names, non-finite values, invalid UTF-8 | Rejected with both json 2.x and 3.x |
70
+ | SD-JWT VC / W3C VC profile rules, revocation, trust discovery | Application responsibility |
71
+ | EdDSA, symmetric holder keys, other `cnf` methods, custom JOSE critical extensions, unencoded payloads | Unsupported |
72
+ | CBOR SD-CWT | Unsupported |
73
+
74
+ `none` is always rejected. Holder keys are P-256/P-384/P-521 or RSA with a 2048–8192 bit odd modulus and an odd public exponent of at least 3. RSA issuer keys also require at least 2048 bits and a valid public exponent. HMAC requires random secrets of at least 32/48/64 bytes for HS256/384/512; PEM, DER, and SSH asymmetric key encodings are rejected as HMAC secrets. Applications may restrict holder signature algorithms further with `key_binding_algorithms:`. Signing uses jwt's token API; JSON signatures, including unprotected `alg`, use its JWA implementation over the unchanged encoded input. PSS verification calls OpenSSL directly to enforce JOSE's exact salt length, which ruby-jwt's permissive PSS verification does not enforce. No signature primitive is implemented here.
75
+
76
+ ## JWS JSON
77
+
78
+ ```ruby
79
+ flattened = credential.to_json(format: :flattened)
80
+ general = credential.to_json(format: :general)
81
+ verifier.verify(SDJWT.parse(flattened).present(only: ["/name"], key_binding: {
82
+ key: holder_key, alg: "ES256", aud: "https://verifier.example", nonce: "transaction-nonce"
83
+ }))
84
+ ```
85
+
86
+ General JSON verification requires **every signature** to pass. RFC 9901 section 8.3 places `disclosures` and `kb_jwt` only in the first signature's unprotected header; the same disclosure set is processed for all signatures, and key binding covers the first signature's Compact representation. Additional signatures are preserved when presenting. A multi-signature token must be written as General JSON. A token with other unprotected headers must remain JSON because Compact cannot retain those headers. Issuance creates one signature; externally co-signed General tokens are accepted.
87
+
88
+ Unprotected headers are not trustworthy identity evidence. Even protected headers and payloads passed to a key resolver have not yet been authenticated. Restrict `iss`/`kid` resolution to an explicit trust configuration; never fetch arbitrary header URLs. `headers` contains the merged headers for each verified signature; use the original `signatures` representation to distinguish protected from unprotected metadata.
89
+
90
+ ## Verification policy and limits
91
+
92
+ `Verifier.new` requires a nonempty signing algorithm allowlist. `required_claims:` defines validity claims that must survive disclosure; hidden `exp`, `aud`, or `cnf` claims cannot otherwise be detected. `issuer:` requires an exact issuer match. A present credential `aud` must match `audience:`; to require its presence, add `aud` to `required_claims`. `exp` and `nbf` are checked after disclosure reconstruction; `leeway:` defaults to zero.
93
+
94
+ Set `require_key_binding: true` based on your application's policy, not on whether a KB-JWT happens to arrive. With it enabled, a nonempty `audience:` and `nonce:` are required, and `iat` must fall between the current time minus `max_kb_age` and the current time. Without it, any supplied KB-JWT is structurally parsed but does not establish holder authentication; `result.key_binding_verified` remains false. Nonce generation, transaction replay storage, key revocation, credential profile rules, and required `typ` values belong to the application.
95
+
96
+ Default limits are 1 MiB serialized input, 1000 disclosures, nesting depth 32, and 16 General signatures. Configure `max_bytes:`, `max_disclosures:`, `max_depth:`, and `max_signatures:` on parsing/verifying; issuance accepts the first three. Verifying an existing `Token` reapplies the verifier's limits. Cryptographic inputs always use strict parsing; `strict: false` is rejected rather than downgrading errors to warnings.
97
+
98
+ Failures use `SDJWT::ParseError`, `ValidationError` (with `pointer` for disclosure path failures), `ConversionError`, or `VerificationError`, all under `SDJWT::Error`. Library errors omit claim values and salts. Application callback errors remain the application's responsibility.
99
+
100
+ ## Development and reference data
101
+
102
+ ```sh
103
+ bundle install
104
+ bundle exec rake test
105
+ COVERAGE=1 bundle exec rake test
106
+ bundle exec rubocop
107
+ bundle exec rbs -I sig validate
108
+ bundle exec ruby script/benchmark.rb
109
+ python3 -m venv tmp/interop-venv
110
+ tmp/interop-venv/bin/python -m pip install --require-hashes -r script/python_requirements.txt
111
+ bundle exec ruby script/interop.rb
112
+ ```
113
+
114
+ Tests fetch RFC 9901 by a pinned SHA-256 into ignored `tmp/rfc/sources`, then extract all 57 source/artwork blocks and 87 fixed disclosure/digest pairs into ignored `tmp/rfc/fixtures`, with the source notices and Revised BSD notification. Nothing fetched is committed or packaged. The package includes only this project's MIT implementation, original protocol metadata, types, and documentation. `script/python_requirements.txt` pins the Python reference and dependency archive hashes; their source and license notices remain in the ignored virtual environment.
115
+
116
+ Display line wrapping is removed inside encoded strings and token blocks. Original text and the extraction inventory are retained next to each fixture. Illustrative snippets with ellipses remain text. The section 8.3 General example uses two illustrative signatures that do not verify with the Appendix A.5 key; it is tested for structural preservation and disclosure reconstruction, with signature rejection explicitly checked. Generated two-key General tokens test successful all-signature verification. Issuance uses compact JSON, while RFC disclosures often contain spaces; original RFC disclosures and digests are checked byte-for-byte, and deterministic issuance is tested separately with compact encoding.
117
+
118
+ Interoperability compares Python `sd-jwt` 0.10.4 in both directions for nested claims, array entries, decoys, holder presentations and key binding, plus all eight complete RFC tokens. Its older JSON serialization is not used as an oracle for RFC 9901's final JSON syntax. The OpenWallet Foundation JavaScript implementation was also consulted for current RFC coverage. RFC errata were checked on 2026-10-11; none were listed. `script/update_registry.rb` audits the IANA JWT claim registrations without importing registry documents into the package.
119
+
120
+ For deterministic tests only, explicitly require `sd_jwt/testing` and wrap issuance in `SDJWT::Testing.with_salts([...])`. Never use supplied salts in production.
121
+
122
+ See [SECURITY.md](SECURITY.md) for reporting and audit status. Released under the [MIT license](LICENSE.txt).
data/SECURITY.md ADDED
@@ -0,0 +1,14 @@
1
+ # Security policy
2
+
3
+ Report suspected vulnerabilities privately through [GitHub security advisories](https://github.com/ydah/sd_jwt/security/advisories/new).
4
+ If private reporting is unavailable, email t.yudai92@gmail.com. Do not include live credentials, private keys, or personal disclosures in a public issue.
5
+
6
+ Security fixes target the latest published release. Upgrade to the latest release before reporting an already resolved issue.
7
+
8
+ `SDJWT.parse` and `Token#unverified_claims` do not authenticate credentials. Only consume trusted claims from `Verifier#verify`.
9
+ The application must establish issuer trust, restrict algorithms, require the validity claims its profile needs, and decide whether holder key binding is mandatory.
10
+ Use fresh unpredictable nonces, enforce one-time transaction use, keep signing keys out of logs, and validate the credential's profile and issuer-supplied metadata separately.
11
+
12
+ No independent external human security audit has been completed for this initial release. Automated negative tests and interoperability checks do not replace that audit.
13
+
14
+ An independent implementation review on 2026-10-11 added regression tests and fixes for asymmetric key encodings used as HMAC secrets, undersized HMAC/RSA keys, invalid RSA public exponents, and nonstandard RSA-PSS salt lengths. It also checked disclosure reconstruction, transaction binding, time boundaries, and parser limits. This review did not audit OpenSSL or ruby-jwt internals, perform timing analysis, or assess an application's issuer trust and replay controls.
data/data/protocol.yml ADDED
@@ -0,0 +1,14 @@
1
+ # Original protocol metadata; no RFC text, examples, or grammars are included.
2
+ claims:
3
+ _sd: {type: array_of_digest_strings, section: '4.2.4.1'}
4
+ '...': {type: digest_string, section: '4.2.4.2'}
5
+ _sd_alg: {type: hash_algorithm, section: '4.1.1'}
6
+ sd_hash: {type: presentation_digest, section: '4.3'}
7
+ hash_algorithms:
8
+ sha-256: {bytes: 32, default: true}
9
+ sha-384: {bytes: 48, default: false}
10
+ sha-512: {bytes: 64, default: false}
11
+ media_types:
12
+ - application/sd-jwt
13
+ - application/sd-jwt+json
14
+ - application/kb+jwt
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "jwt"
7
+ require "set" # rubocop:disable Lint/RedundantRequireStatement -- Required by Ruby 3.3 and 3.4.
8
+
9
+ module SDJWT
10
+ module Codec
11
+ MAX_BYTES = 1_048_576
12
+ MAX_DEPTH = 32
13
+ MAX_DISCLOSURES = 1000
14
+ MAX_SIGNATURES = 16
15
+ ALGORITHMS = %w[ES256 ES384 ES512 RS256 RS384 RS512 PS256 PS384 PS512 HS256 HS384 HS512].freeze
16
+
17
+ # Keep JSON generation local: some TruffleRuby versions round Float#to_s incorrectly.
18
+ class SigningToken < JWT::Token
19
+ def encoded_header
20
+ @encoded_header ||= Codec.encode(Codec.generate(header))
21
+ end
22
+
23
+ def encoded_payload
24
+ @encoded_payload ||= Codec.encode(Codec.generate(payload))
25
+ end
26
+ end
27
+
28
+ module_function
29
+
30
+ def limits(max_bytes:, max_depth:, max_disclosures:, max_signatures: MAX_SIGNATURES)
31
+ values = { max_bytes: max_bytes, max_depth: max_depth, max_disclosures: max_disclosures, max_signatures: max_signatures }
32
+ raise ArgumentError, "Limits must be positive integers" unless values.values.all? { |n| n.is_a?(Integer) && n.positive? }
33
+
34
+ values.freeze
35
+ end
36
+
37
+ def parse_json(source, max_depth: MAX_DEPTH)
38
+ raise ParseError, "JSON input must be a string" unless source.is_a?(String)
39
+
40
+ source = source.dup.force_encoding(Encoding::UTF_8)
41
+ raise ParseError, "Invalid UTF-8 JSON" unless source.valid_encoding?
42
+
43
+ value = JSON.parse(source, allow_duplicate_key: false, max_nesting: max_depth, allow_nan: false)
44
+ copy(value, max_depth: max_depth)
45
+ rescue JSON::ParserError, ValidationError, EncodingError, ArgumentError
46
+ raise ParseError, "Invalid JSON", cause: nil
47
+ end
48
+
49
+ def generate(value)
50
+ JSON.generate(precise(value), max_nesting: false, allow_nan: false)
51
+ rescue JSON::GeneratorError, EncodingError, ArgumentError
52
+ raise ValidationError, "Invalid JSON value", cause: nil
53
+ end
54
+
55
+ def precise(value)
56
+ case value
57
+ when Hash then value.transform_values { |item| precise(item) }
58
+ when Array then value.map { |item| precise(item) }
59
+ when Float
60
+ raise ValidationError, "Non-finite number" unless value.finite?
61
+ return value unless RUBY_ENGINE == "truffleruby"
62
+
63
+ number = format("%.17g", value)
64
+ number += ".0" unless number.match?(/[.eE]/)
65
+ JSON::Fragment.new(number)
66
+ else value
67
+ end
68
+ end
69
+
70
+ def copy(value, max_depth: MAX_DEPTH, depth: 0)
71
+ raise ValidationError, "Nesting limit exceeded" if depth > max_depth
72
+
73
+ case value
74
+ when Hash
75
+ raise ValidationError, "JSON members must be strings" unless value.keys.all?(String)
76
+
77
+ value.to_h { |key, item| [key.dup, copy(item, max_depth: max_depth, depth: depth + 1)] }
78
+ when Array then value.map { |item| copy(item, max_depth: max_depth, depth: depth + 1) }
79
+ when String
80
+ raise ValidationError, "Invalid UTF-8 string" unless value.encoding == Encoding::UTF_8 || value.ascii_only?
81
+ raise ValidationError, "Invalid UTF-8 string" unless value.valid_encoding?
82
+
83
+ value.dup
84
+ when Float
85
+ raise ValidationError, "Non-finite number" unless value.finite?
86
+
87
+ value
88
+ when Integer, TrueClass, FalseClass, NilClass then value
89
+ else raise ValidationError, "Unsupported JSON value"
90
+ end
91
+ end
92
+
93
+ def freeze_tree(value)
94
+ case value
95
+ when Hash then value.each do |key, item|
96
+ key.freeze
97
+ freeze_tree(item)
98
+ end
99
+ when Array then value.each { |item| freeze_tree(item) }
100
+ end
101
+ value.freeze
102
+ end
103
+
104
+ def encode(bytes)
105
+ Base64.urlsafe_encode64(bytes, padding: false)
106
+ end
107
+
108
+ def decode(value, empty: false)
109
+ raise ParseError, "Invalid base64url" unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]*\z/) && (empty || !value.empty?)
110
+
111
+ decoded = Base64.urlsafe_decode64(value)
112
+ raise ParseError, "Noncanonical base64url" unless encode(decoded) == value
113
+
114
+ decoded
115
+ rescue ArgumentError
116
+ raise ParseError, "Invalid base64url", cause: nil
117
+ end
118
+
119
+ def signing_algorithm(algorithm)
120
+ raise ValidationError, "Unsupported signing algorithm" unless ALGORITHMS.include?(algorithm)
121
+
122
+ algorithm
123
+ end
124
+
125
+ def allowlist(algorithms)
126
+ unless algorithms.is_a?(Array) && !algorithms.empty? && algorithms.all? { |alg| ALGORITHMS.include?(alg) }
127
+ raise ArgumentError, "A nonempty supported signing algorithm allowlist is required"
128
+ end
129
+
130
+ algorithms.map(&:dup).each(&:freeze).freeze
131
+ end
132
+
133
+ def validate_claim_types(claims)
134
+ %w[iss sub jti].each do |name|
135
+ raise ValidationError, "Invalid registered claim type" if claims.key?(name) && !claims[name].is_a?(String)
136
+ end
137
+ %w[exp nbf iat].each do |name|
138
+ next unless claims.key?(name)
139
+
140
+ value = claims[name]
141
+ raise ValidationError, "Invalid NumericDate claim" unless value.is_a?(Numeric) && value.finite?
142
+ end
143
+ if claims.key?("aud")
144
+ audience = claims["aud"]
145
+ unless audience.is_a?(String) || (audience.is_a?(Array) && !audience.empty? && audience.all?(String))
146
+ raise ValidationError, "Invalid audience claim type"
147
+ end
148
+ end
149
+ raise ValidationError, "Invalid confirmation claim type" if claims.key?("cnf") && !claims["cnf"].is_a?(Hash)
150
+ end
151
+
152
+ def sign(payload, key:, algorithm:, header: {})
153
+ signing_algorithm(algorithm)
154
+ validate_hmac_key(key, algorithm)
155
+ validate_rsa_key(key.is_a?(JWT::JWK::KeyBase) ? key.verify_key : key) if algorithm.start_with?("RS", "PS")
156
+ raise ValidationError, "Protected header algorithm mismatch" if header.key?("alg") && header["alg"] != algorithm
157
+ raise ValidationError, "Unsupported protected header" if header.keys.intersect?(%w[crit b64 disclosures kb_jwt])
158
+
159
+ token = SigningToken.new(payload: payload, header: header)
160
+ token.sign!(key: key, algorithm: algorithm)
161
+ token.jwt
162
+ rescue JWT::EncodeError, JWT::DecodeError, OpenSSL::OpenSSLError, ArgumentError
163
+ raise ValidationError, "Signing failed", cause: nil
164
+ end
165
+
166
+ def validate_hmac_key(key, algorithm)
167
+ return unless algorithm.start_with?("HS")
168
+
169
+ secret = key.is_a?(JWT::JWK::KeyBase) ? key.verify_key : key
170
+ minimum = algorithm.delete_prefix("HS").to_i / 8
171
+ raise ValidationError, "HMAC requires a secret at least as long as the hash output" unless secret.is_a?(String) && secret.bytesize >= minimum
172
+ raise ValidationError, "Asymmetric key material cannot be an HMAC secret" if asymmetric_key_material?(secret)
173
+ end
174
+
175
+ def asymmetric_key_material?(secret)
176
+ return true if secret.b.include?("-----BEGIN ") || secret.b.match?(/\A\s*(?:ssh-|ecdsa-sha2-)/n)
177
+
178
+ # Also recognize DER encodings. Supplying a password prevents terminal prompts.
179
+ OpenSSL::PKey.read(secret, "")
180
+ true
181
+ rescue OpenSSL::OpenSSLError, ArgumentError
182
+ false
183
+ end
184
+
185
+ def validate_rsa_key(key)
186
+ return if key.is_a?(OpenSSL::PKey::RSA) && key.n.num_bits >= 2048 && key.n.to_i.odd? && key.e.to_i >= 3 && key.e.to_i.odd? && key.e < key.n
187
+
188
+ raise ValidationError, "RSA requires a valid public exponent and an odd modulus of at least 2048 bits"
189
+ end
190
+
191
+ def jwt_parts(compact, max_depth: MAX_DEPTH)
192
+ raise ParseError, "Invalid JWS" unless compact.is_a?(String)
193
+
194
+ parts = compact.split(".", -1)
195
+ raise ParseError, "Invalid JWS" unless parts.length == 3
196
+
197
+ header = parts[0].empty? ? {} : parse_json(decode(parts[0]), max_depth: max_depth)
198
+ payload = parse_json(decode(parts[1]), max_depth: max_depth)
199
+ decode(parts[2])
200
+ raise ParseError, "JWS header and payload must be objects" unless header.is_a?(Hash) && payload.is_a?(Hash)
201
+
202
+ [parts, header, payload]
203
+ end
204
+
205
+ def verify_signature(compact, key:, algorithms:, unprotected: {}, max_depth: MAX_DEPTH)
206
+ parts, protected_header, payload = jwt_parts(compact, max_depth: max_depth)
207
+ raise VerificationError, "Overlapping JWS headers" if protected_header.keys.intersect?(unprotected.keys)
208
+ raise VerificationError, "Unsupported critical or unencoded JWS" if protected_header.keys.intersect?(%w[crit b64])
209
+ raise VerificationError, "Unsupported unprotected security header" if unprotected.keys.intersect?(%w[crit b64])
210
+
211
+ header = protected_header.merge(unprotected)
212
+ algorithm = header["alg"]
213
+ raise VerificationError, "Signing algorithm is not allowed" unless algorithms.include?(algorithm)
214
+ raise VerificationError, "Missing verification key" if key.nil?
215
+
216
+ if key.is_a?(JWT::JWK::KeyBase)
217
+ raise VerificationError, "Verification JWK algorithm mismatch" if key[:alg] && key[:alg] != algorithm
218
+ raise VerificationError, "Verification JWK use mismatch" if key[:use] && key[:use] != "sig"
219
+ if key[:key_ops] && (!key[:key_ops].is_a?(Array) || !key[:key_ops].include?("verify"))
220
+ raise VerificationError, "Verification JWK does not permit verification"
221
+ end
222
+
223
+ key = key.verify_key
224
+ end
225
+ validate_hmac_key(key, algorithm)
226
+ validate_rsa_key(key) if algorithm.start_with?("RS", "PS")
227
+ signature = decode(parts[2])
228
+ expected_bytes = { "ES256" => 64, "ES384" => 96, "ES512" => 132 }[algorithm]
229
+ raise VerificationError, "Invalid ECDSA signature length" if expected_bytes && signature.bytesize != expected_bytes
230
+
231
+ data = parts.first(2).join(".")
232
+ valid = if algorithm.start_with?("PS")
233
+ # ruby-jwt accepts arbitrary PSS salt lengths; JOSE requires the hash length.
234
+ digest = algorithm.sub("PS", "SHA")
235
+ key.verify_pss(digest, signature, data, salt_length: :digest, mgf1_hash: digest)
236
+ else
237
+ verifiers = JWT::JWA.create_verifiers(algorithms: [algorithm], keys: [key], preferred_algorithm: algorithm)
238
+ verifiers.any? { |verifier| verifier.verify(data: data, signature: signature) }
239
+ end
240
+ raise VerificationError, "Invalid signature" unless valid
241
+
242
+ [payload, header]
243
+ rescue JWT::DecodeError, JWT::EncodeError, OpenSSL::OpenSSLError, ArgumentError, TypeError, NoMethodError
244
+ raise VerificationError, "Signature verification failed", cause: nil
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SDJWT
6
+ class Disclosure
7
+ attr_reader :encoded, :salt, :name, :value
8
+
9
+ def self.parse(encoded, max_depth: Codec::MAX_DEPTH)
10
+ raw = Codec.parse_json(Codec.decode(encoded), max_depth: max_depth)
11
+ new(raw, encoded: encoded, max_depth: max_depth)
12
+ end
13
+
14
+ def initialize(raw, encoded: nil, max_depth: Codec::MAX_DEPTH)
15
+ raise ParseError, "Invalid disclosure structure" unless raw.is_a?(Array) && [2, 3].include?(raw.length) && raw[0].is_a?(String)
16
+ raise ParseError, "Invalid disclosure claim name" if raw.length == 3 && (!raw[1].is_a?(String) || %w[_sd ...].include?(raw[1]))
17
+
18
+ raw = Codec.copy(raw, max_depth: max_depth)
19
+ @salt = raw[0].freeze
20
+ @name = raw.length == 3 ? raw[1].freeze : nil
21
+ @value = Codec.freeze_tree(raw.last)
22
+ @encoded = (encoded || Codec.encode(Codec.generate(raw))).dup.freeze
23
+ freeze
24
+ end
25
+
26
+ def array_element?
27
+ name.nil?
28
+ end
29
+
30
+ def digest(algorithm = "sha-256")
31
+ HashAlg.digest(encoded, algorithm)
32
+ end
33
+
34
+ def to_s
35
+ encoded
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ class Error < StandardError; end
5
+ class ParseError < Error; end
6
+
7
+ class ValidationError < Error
8
+ attr_reader :pointer
9
+
10
+ def initialize(message, pointer: "")
11
+ @pointer = pointer
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class ConversionError < Error; end
17
+ class VerificationError < Error; end
18
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ module HashAlg
5
+ ALGORITHMS = { "sha-256" => "SHA256", "sha-384" => "SHA384", "sha-512" => "SHA512" }.freeze
6
+
7
+ module_function
8
+
9
+ def validate(name, allowed: ALGORITHMS.keys)
10
+ raise ValidationError, "Unsupported disclosure hash algorithm" unless ALGORITHMS.key?(name) && allowed.include?(name)
11
+
12
+ name
13
+ end
14
+
15
+ def digest(value, algorithm = "sha-256")
16
+ validate(algorithm)
17
+ raise ValidationError, "Digest input must be ASCII" unless value.is_a?(String) && value.ascii_only?
18
+
19
+ Codec.encode(OpenSSL::Digest.digest(ALGORITHMS.fetch(algorithm), value))
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ class Issuer
5
+ def initialize(signing_key:, alg: "ES256", hash_alg: "sha-256", max_bytes: Codec::MAX_BYTES,
6
+ max_depth: Codec::MAX_DEPTH, max_disclosures: Codec::MAX_DISCLOSURES)
7
+ @key = signing_key
8
+ @algorithm = Codec.signing_algorithm(alg)
9
+ @hash_algorithm = HashAlg.validate(hash_alg)
10
+ @limits = Codec.limits(max_bytes: max_bytes, max_depth: max_depth, max_disclosures: max_disclosures)
11
+ end
12
+
13
+ def issue(claims, disclose: [], decoys: 0, holder_jwk: nil, header: {})
14
+ raise ValidationError, "Claims and header must be objects" unless claims.is_a?(Hash) && header.is_a?(Hash)
15
+ raise ArgumentError, "Decoys must be a nonnegative integer" unless decoys.is_a?(Integer) && decoys >= 0
16
+ unless disclose.is_a?(Array) && disclose.length <= @limits[:max_disclosures] && decoys <= @limits[:max_disclosures]
17
+ raise ValidationError, "Disclosure limit exceeded"
18
+ end
19
+ raise ValidationError, "Duplicate disclosure path" unless disclose.uniq.length == disclose.length
20
+
21
+ payload = Codec.copy(claims, max_depth: @limits[:max_depth])
22
+ reject_reserved(payload)
23
+ Codec.validate_claim_types(payload)
24
+
25
+ if holder_jwk
26
+ raise ValidationError, "Confirmation claim already exists" if payload.key?("cnf")
27
+
28
+ payload["cnf"] = { "jwk" => KeyBinding.public_jwk(holder_jwk) }
29
+ end
30
+ finalized = Set.new
31
+ object_parents = disclose.map { |path| Pointer.locate(payload, path).first }.grep(Hash).map(&:object_id).uniq
32
+ raise ValidationError, "Disclosure and decoy limit exceeded" if disclose.length + (decoys * object_parents.length) > @limits[:max_disclosures]
33
+
34
+ disclosures = disclose.sort_by { |path| -Pointer.tokens(path).length }.map do |path|
35
+ parent, name = Pointer.locate(payload, path)
36
+ add_decoys(parent[name], decoys, finalized)
37
+ raw = parent.is_a?(Array) ? [salt, parent[name]] : [salt, name, parent.delete(name)]
38
+ disclosure = Disclosure.new(raw, max_depth: @limits[:max_depth] + 2)
39
+ digest = disclosure.digest(@hash_algorithm)
40
+ if parent.is_a?(Array)
41
+ parent[name] = { "..." => digest }
42
+ else
43
+ (parent["_sd"] ||= []) << digest
44
+ end
45
+ disclosure.encoded
46
+ end
47
+ add_decoys(payload, decoys, finalized)
48
+ payload["_sd_alg"] = @hash_algorithm
49
+ compact = Codec.sign(payload, key: @key, algorithm: @algorithm, header: Codec.copy(header))
50
+ SDJWT.parse(([compact] + disclosures + [""]).join("~"), **@limits)
51
+ end
52
+
53
+ private
54
+
55
+ def salt
56
+ if defined?(Testing) && Thread.current[:sd_jwt_test_salts]
57
+ return Thread.current[:sd_jwt_test_salts].shift || raise(ValidationError, "Test salts exhausted")
58
+ end
59
+
60
+ Codec.encode(SecureRandom.random_bytes(16))
61
+ end
62
+
63
+ def reject_reserved(value)
64
+ case value
65
+ when Hash
66
+ raise ValidationError, "Reserved claim name" if value.keys.intersect?(%w[_sd _sd_alg ...])
67
+
68
+ value.each_value { |item| reject_reserved(item) }
69
+ when Array then value.each { |item| reject_reserved(item) }
70
+ end
71
+ end
72
+
73
+ def add_decoys(value, count, finalized)
74
+ case value
75
+ when Hash
76
+ value.each_value { |item| add_decoys(item, count, finalized) }
77
+ return unless value.key?("_sd") && finalized.add?(value.object_id)
78
+
79
+ count.times { value["_sd"] << HashAlg.digest(Codec.encode(SecureRandom.random_bytes(32)), @hash_algorithm) }
80
+ value["_sd"].sort!
81
+ when Array then value.each { |item| add_decoys(item, count, finalized) }
82
+ end
83
+ end
84
+ end
85
+ end