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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +122 -0
- data/SECURITY.md +14 -0
- data/data/protocol.yml +14 -0
- data/lib/sd_jwt/codec.rb +247 -0
- data/lib/sd_jwt/disclosure.rb +38 -0
- data/lib/sd_jwt/errors.rb +18 -0
- data/lib/sd_jwt/hash_alg.rb +22 -0
- data/lib/sd_jwt/issuer.rb +85 -0
- data/lib/sd_jwt/key_binding.rb +86 -0
- data/lib/sd_jwt/pointer.rb +37 -0
- data/lib/sd_jwt/processor.rb +107 -0
- data/lib/sd_jwt/serialization.rb +92 -0
- data/lib/sd_jwt/testing.rb +20 -0
- data/lib/sd_jwt/token.rb +97 -0
- data/lib/sd_jwt/verifier.rb +109 -0
- data/lib/sd_jwt/version.rb +6 -0
- data/lib/sd_jwt.rb +25 -0
- data/sig/sd_jwt.rbs +71 -0
- metadata +106 -0
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
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
|
data/lib/sd_jwt/codec.rb
ADDED
|
@@ -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
|