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.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ module KeyBinding
5
+ module_function
6
+
7
+ def public_jwk(jwk)
8
+ raise ValidationError, "Holder JWK must be an object" unless jwk.is_a?(Hash)
9
+
10
+ object = Codec.copy(jwk)
11
+ unless %w[EC RSA].include?(object["kty"]) && !object.keys.intersect?(%w[d p q dp dq qi oth k])
12
+ raise ValidationError, "Holder JWK must contain only an asymmetric public key"
13
+ end
14
+ raise ValidationError, "Holder JWK does not permit signatures" if object.key?("use") && object["use"] != "sig"
15
+ if object.key?("key_ops") && (!object["key_ops"].is_a?(Array) || !object["key_ops"].include?("verify"))
16
+ raise ValidationError, "Holder JWK does not permit verification"
17
+ end
18
+
19
+ validate_key_size(object)
20
+ key = import_public_key(object)
21
+ Codec.validate_rsa_key(key) if object["kty"] == "RSA"
22
+ object
23
+ rescue ParseError, JWT::JWKError, OpenSSL::OpenSSLError, ArgumentError, TypeError
24
+ raise ValidationError, "Invalid holder public key", cause: nil
25
+ end
26
+
27
+ def import_public_key(object)
28
+ JWT::JWK.import(object).public_key
29
+ rescue StandardError
30
+ # JRuby's crypto provider can raise Java exceptions for invalid key material.
31
+ raise ValidationError, "Invalid holder public key", cause: nil
32
+ end
33
+
34
+ def validate_key_size(jwk)
35
+ if jwk["kty"] == "EC"
36
+ size = { "P-256" => 32, "P-384" => 48, "P-521" => 66 }[jwk["crv"]]
37
+ unless size && %w[x y].all? { |coordinate| Codec.decode(jwk[coordinate]).bytesize == size }
38
+ raise ValidationError, "Unsupported holder elliptic curve or coordinate size"
39
+ end
40
+ else
41
+ modulus = Codec.decode(jwk["n"])
42
+ exponent = Codec.decode(jwk["e"])
43
+ unless (256..1024).cover?(modulus.bytesize) && modulus.getbyte(0) >= 128 && (1..8).cover?(exponent.bytesize) && exponent.getbyte(0).positive?
44
+ raise ValidationError, "Holder RSA key must use a 2048 to 8192 bit modulus and bounded exponent"
45
+ end
46
+ unless modulus.getbyte(-1).odd? && exponent.getbyte(-1).odd? && exponent.unpack1("H*").to_i(16) >= 3
47
+ raise ValidationError, "Invalid holder RSA modulus or exponent"
48
+ end
49
+ end
50
+ end
51
+
52
+ def create(sd_jwt, key:, alg:, aud:, nonce:, hash_algorithm: "sha-256", iat: Time.now.to_i)
53
+ raise ValidationError, "Holder signatures must be asymmetric" if alg.to_s.start_with?("HS")
54
+ unless aud.is_a?(String) && !aud.empty? && nonce.is_a?(String) && !nonce.empty? && iat.is_a?(Integer)
55
+ raise ValidationError, "Invalid key binding transaction"
56
+ end
57
+
58
+ payload = { "iat" => iat, "aud" => aud, "nonce" => nonce, "sd_hash" => HashAlg.digest(sd_jwt, hash_algorithm) }
59
+ Codec.sign(payload, key: key, algorithm: alg, header: { "typ" => "kb+jwt" })
60
+ end
61
+
62
+ def verify(signature, claims:, hash_algorithm:, algorithms:, audience:, nonce:, max_age:, now:, max_depth:)
63
+ jwk = claims.dig("cnf", "jwk") if claims["cnf"].is_a?(Hash)
64
+ public_jwk(jwk)
65
+ key = JWT::JWK.import(jwk)
66
+ kb_payload, kb_header = Codec.verify_signature(signature.fetch("kb_jwt"), key: key, algorithms: algorithms, max_depth: max_depth)
67
+ raise VerificationError, "Invalid key binding type" unless kb_header["typ"] == "kb+jwt"
68
+
69
+ iat = kb_payload["iat"]
70
+ raise VerificationError, "Invalid key binding issuance time" unless iat.is_a?(Numeric) && iat.finite? && iat <= now && iat >= now - max_age
71
+ raise VerificationError, "Key binding transaction mismatch" unless kb_payload["aud"] == audience && kb_payload["nonce"] == nonce
72
+
73
+ sd_jwt = ([signature.fetch("jwt")] + signature.fetch("disclosures") + [""]).join("~")
74
+ expected = HashAlg.digest(sd_jwt, hash_algorithm)
75
+ actual = kb_payload["sd_hash"]
76
+ unless actual.is_a?(String) && actual.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(actual, expected)
77
+ raise VerificationError, "Key binding digest mismatch"
78
+ end
79
+
80
+ Verifier.check_times(kb_payload, now)
81
+ true
82
+ rescue JWT::JWKError, KeyError, OpenSSL::OpenSSLError, ArgumentError, TypeError
83
+ raise VerificationError, "Invalid key binding", cause: nil
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ module Pointer
5
+ module_function
6
+
7
+ def tokens(path)
8
+ unless path.is_a?(String) && path.start_with?("/") && !path.match?(/~(?:[^01]|$)/)
9
+ raise ValidationError.new("Invalid disclosure JSON Pointer", pointer: path.is_a?(String) ? path : "")
10
+ end
11
+
12
+ path.split("/", -1).drop(1).map { |part| part.gsub("~1", "/").gsub("~0", "~") }
13
+ end
14
+
15
+ def append(path, name)
16
+ "#{path}/#{name.to_s.gsub("~", "~0").gsub("/", "~1")}"
17
+ end
18
+
19
+ def locate(root, path)
20
+ parts = tokens(path)
21
+ parent = parts[0...-1].reduce(root) { |value, name| value_at(value, name, path) }
22
+ name = parts.last
23
+ value_at(parent, name, path)
24
+ [parent, parent.is_a?(Array) ? Integer(name, 10) : name]
25
+ end
26
+
27
+ def value_at(value, name, path)
28
+ if value.is_a?(Hash) && value.key?(name)
29
+ value[name]
30
+ elsif value.is_a?(Array) && name.match?(/\A(?:0|[1-9][0-9]*)\z/) && name.to_i < value.length
31
+ value[name.to_i]
32
+ else
33
+ raise ValidationError.new("Disclosure path does not exist", pointer: path)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ class Processor
5
+ attr_reader :claims, :paths, :hash_algorithm
6
+
7
+ def initialize(payload, disclosures, max_depth: Codec::MAX_DEPTH, hash_algorithms: HashAlg::ALGORITHMS.keys)
8
+ @max_depth = max_depth
9
+ @hash_algorithm = HashAlg.validate(payload.fetch("_sd_alg", "sha-256"), allowed: hash_algorithms)
10
+ @digests = compute_digests(disclosures)
11
+ @seen = Set.new
12
+ @used = Set.new
13
+ @paths = {}
14
+ @claims = embed_disclosures(payload, "", [], 0)
15
+ check_unreferenced
16
+ end
17
+
18
+ private
19
+
20
+ # RFC 9901 section 7.1 step 3.a: hash the original encoded disclosures.
21
+ def compute_digests(disclosures)
22
+ disclosures.each_with_object({}) do |encoded, result|
23
+ disclosure = Disclosure.parse(encoded, max_depth: @max_depth + 2)
24
+ digest = disclosure.digest(@hash_algorithm)
25
+ raise VerificationError, "Duplicate disclosure" if result.key?(digest)
26
+
27
+ result[digest] = disclosure
28
+ end
29
+ end
30
+
31
+ # RFC 9901 section 7.1 step 3.b-f: recursively restore disclosed claims.
32
+ def embed_disclosures(value, path, dependencies, depth)
33
+ raise VerificationError, "Nesting limit exceeded" if depth > @max_depth
34
+
35
+ case value
36
+ when Hash then embed_object(value, path, dependencies, depth)
37
+ when Array
38
+ value.each_with_index.with_object([]) do |(item, index), result|
39
+ child_path = Pointer.append(path, index)
40
+ if item.is_a?(Hash) && item.key?("...")
41
+ raise VerificationError, "Invalid array placeholder" unless item.keys == ["..."]
42
+
43
+ disclosure = find_disclosure(item["..."])
44
+ next unless disclosure
45
+ raise VerificationError, "Invalid array disclosure" unless disclosure.array_element?
46
+
47
+ chain = dependencies + [disclosure.encoded]
48
+ @paths[child_path] = chain
49
+ result << embed_disclosures(disclosure.value, child_path, chain, depth + 1)
50
+ else
51
+ result << embed_disclosures(item, child_path, dependencies, depth + 1)
52
+ end
53
+ end
54
+ else value
55
+ end
56
+ end
57
+
58
+ def embed_object(value, path, dependencies, depth)
59
+ raise VerificationError, "Reserved object member" if value.key?("...")
60
+ raise VerificationError, "Hash algorithm must be a root claim" if depth.positive? && value.key?("_sd_alg")
61
+
62
+ result = value.each_with_object({}) do |(name, item), output|
63
+ next if %w[_sd _sd_alg].include?(name)
64
+
65
+ output[name] = embed_disclosures(item, Pointer.append(path, name), dependencies, depth + 1)
66
+ end
67
+ digests = value.fetch("_sd", [])
68
+ raise VerificationError, "Invalid digest array" unless digests.is_a?(Array)
69
+
70
+ digests.each do |digest|
71
+ disclosure = find_disclosure(digest)
72
+ next unless disclosure
73
+ raise VerificationError, "Invalid object disclosure" if disclosure.array_element?
74
+ raise VerificationError, "Disclosed claim collision" if result.key?(disclosure.name) || %w[_sd_alg].include?(disclosure.name)
75
+
76
+ child_path = Pointer.append(path, disclosure.name)
77
+ chain = dependencies + [disclosure.encoded]
78
+ @paths[child_path] = chain
79
+ result[disclosure.name] = embed_disclosures(disclosure.value, child_path, chain, depth + 1)
80
+ end
81
+ result
82
+ end
83
+
84
+ def find_disclosure(digest)
85
+ raise VerificationError, "Invalid embedded digest" unless digest.is_a?(String)
86
+
87
+ raw = Codec.decode(digest)
88
+ expected_length = { "sha-256" => 32, "sha-384" => 48, "sha-512" => 64 }.fetch(@hash_algorithm)
89
+ raise VerificationError, "Invalid embedded digest length" unless raw.bytesize == expected_length
90
+
91
+ check_duplicates(digest)
92
+ disclosure = @digests[digest]
93
+ @used << digest if disclosure
94
+ disclosure
95
+ end
96
+
97
+ # RFC 9901 section 7.1 step 4: includes undisclosed and decoy digests.
98
+ def check_duplicates(digest)
99
+ raise VerificationError, "Duplicate embedded digest" unless @seen.add?(digest)
100
+ end
101
+
102
+ # RFC 9901 section 7.1 step 5: no extraneous disclosures are accepted.
103
+ def check_unreferenced
104
+ raise VerificationError, "Unreferenced disclosure" unless @used.length == @digests.length
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ module Serialization
5
+ module_function
6
+
7
+ def parse(source, limits:)
8
+ raise ParseError, "Input must be a string" unless source.is_a?(String)
9
+ raise ParseError, "Input size limit exceeded" if source.bytesize > limits[:max_bytes]
10
+
11
+ source = source.dup.force_encoding(Encoding::UTF_8)
12
+ raise ParseError, "Invalid UTF-8 input" unless source.valid_encoding?
13
+
14
+ entries = if source.lstrip.start_with?("{")
15
+ parse_json(source, limits)
16
+ else
17
+ parse_compact(source)
18
+ end
19
+ raise ParseError, "Signature limit exceeded" if entries.length > limits[:max_signatures]
20
+
21
+ entries.each do |entry|
22
+ validate_entry(entry, limits)
23
+ end
24
+ Token.new(entries, limits: limits)
25
+ end
26
+
27
+ def parse_compact(source)
28
+ raise ParseError, "Invalid compact serialization" unless source.ascii_only? && source.include?("~")
29
+
30
+ pieces = source.split("~", -1)
31
+ jwt = pieces.shift
32
+ kb_jwt = pieces.pop
33
+ entry = { "jwt" => jwt, "disclosures" => pieces, "unprotected" => {} }
34
+ entry["kb_jwt"] = kb_jwt unless kb_jwt.empty?
35
+ [entry]
36
+ end
37
+
38
+ def parse_json(source, limits)
39
+ object = Codec.parse_json(source, max_depth: limits[:max_depth] + 4)
40
+ raise ParseError, "JWS serialization must be an object" unless object.is_a?(Hash)
41
+
42
+ if object.key?("signatures")
43
+ unless !object.keys.intersect?(%w[signature protected header]) && object["signatures"].is_a?(Array) && !object["signatures"].empty?
44
+ raise ParseError, "Invalid General JWS serialization"
45
+ end
46
+
47
+ entries = object["signatures"]
48
+ else
49
+ entries = [object]
50
+ end
51
+ raise ParseError, "Signature limit exceeded" if entries.length > limits[:max_signatures]
52
+
53
+ first_header = entries.first.is_a?(Hash) ? entries.first.fetch("header", {}) : {}
54
+ raise ParseError, "Invalid first unprotected header" unless first_header.is_a?(Hash)
55
+
56
+ entries.each_with_index.map do |entry, index|
57
+ raise ParseError, "Invalid JWS signature entry" unless entry.is_a?(Hash)
58
+
59
+ header = entry.fetch("header", {})
60
+ raise ParseError, "Invalid unprotected header" unless header.is_a?(Hash)
61
+
62
+ if index.positive? && header.keys.intersect?(%w[disclosures kb_jwt])
63
+ raise ParseError, "General JWS disclosure metadata is permitted only on the first signature"
64
+ end
65
+
66
+ parts = [entry.fetch("protected", ""), object["payload"], entry["signature"]]
67
+ raise ParseError, "Invalid JWS members" unless parts.all?(String)
68
+
69
+ result = { "jwt" => parts.join("."), "disclosures" => first_header.fetch("disclosures", []),
70
+ "unprotected" => header.except("disclosures", "kb_jwt") }
71
+ result["kb_jwt"] = header["kb_jwt"] if header.key?("kb_jwt")
72
+ result
73
+ end
74
+ end
75
+
76
+ def validate_entry(entry, limits)
77
+ _, header, = Codec.jwt_parts(entry.fetch("jwt"), max_depth: limits[:max_depth])
78
+ raise ParseError, "Disclosure metadata must be unprotected" if header.keys.intersect?(%w[disclosures kb_jwt])
79
+ raise ParseError, "Overlapping JWS headers" if header.keys.intersect?(entry.fetch("unprotected").keys)
80
+ if header.keys.intersect?(%w[crit b64]) || entry.fetch("unprotected").keys.intersect?(%w[crit b64])
81
+ raise ParseError, "Unsupported critical or unencoded JWS"
82
+ end
83
+
84
+ disclosures = entry.fetch("disclosures")
85
+ raise ParseError, "Invalid disclosures" unless disclosures.is_a?(Array)
86
+ raise ParseError, "Disclosure limit exceeded" if disclosures.length > limits[:max_disclosures]
87
+
88
+ disclosures.each { |encoded| Disclosure.parse(encoded, max_depth: limits[:max_depth] + 2) }
89
+ Codec.jwt_parts(entry["kb_jwt"], max_depth: limits[:max_depth]) if entry.key?("kb_jwt")
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sd_jwt"
4
+
5
+ module SDJWT
6
+ # Deterministic salts are available only when this test-only file is explicitly required.
7
+ module Testing
8
+ module_function
9
+
10
+ def with_salts(salts)
11
+ previous = Thread.current[:sd_jwt_test_salts]
12
+ raise ArgumentError, "Salts must be strings" unless salts.is_a?(Array) && salts.all?(String)
13
+
14
+ Thread.current[:sd_jwt_test_salts] = salts.map(&:dup)
15
+ yield
16
+ ensure
17
+ Thread.current[:sd_jwt_test_salts] = previous
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ class Token
5
+ attr_reader :signatures, :warnings
6
+
7
+ def initialize(signatures, limits:)
8
+ @limits = limits
9
+ @signatures = Codec.freeze_tree(signatures)
10
+ @warnings = [].freeze
11
+ freeze
12
+ end
13
+
14
+ def disclosures
15
+ signatures.first.fetch("disclosures").dup
16
+ end
17
+
18
+ def issuer_jwt
19
+ signatures.first.fetch("jwt")
20
+ end
21
+
22
+ def key_binding_jwt
23
+ signatures.first["kb_jwt"]
24
+ end
25
+
26
+ # This payload is NOT authenticated. Use Verifier#verify for trusted claims.
27
+ def unverified_claims
28
+ processor(signatures.first).claims
29
+ end
30
+
31
+ def disclosed_paths
32
+ processor(signatures.first).paths.keys
33
+ end
34
+
35
+ def to_s
36
+ raise ConversionError, "Multiple signatures require General JSON serialization" unless signatures.length == 1
37
+
38
+ raise ConversionError, "Unprotected JWS headers require JSON serialization" unless signatures.first.fetch("unprotected").empty?
39
+
40
+ compact(signatures.first)
41
+ end
42
+
43
+ def to_json(*, format: :flattened)
44
+ raise ArgumentError, "Unknown JWS JSON format" unless %i[flattened general].include?(format)
45
+ raise ConversionError, "Multiple signatures require General JSON serialization" if format == :flattened && signatures.length != 1
46
+
47
+ entries = signatures.each_with_index.map do |signature, index|
48
+ protected_header, payload, encoded_signature = signature.fetch("jwt").split(".", -1)
49
+ header = signature.fetch("unprotected").dup
50
+ header["disclosures"] = signature.fetch("disclosures") if index.zero?
51
+ header["kb_jwt"] = signature["kb_jwt"] if signature["kb_jwt"]
52
+ { "protected" => protected_header, "payload" => payload, "signature" => encoded_signature, "header" => header }
53
+ end
54
+ if format == :general
55
+ entries.each { |entry| entry.delete("payload") }
56
+ Codec.generate({ "payload" => signatures.first.fetch("jwt").split(".", -1)[1], "signatures" => entries })
57
+ else
58
+ Codec.generate(entries.first)
59
+ end
60
+ end
61
+
62
+ def present(only:, key_binding: nil)
63
+ raise ArgumentError, "Disclosure paths must be an array" unless only.is_a?(Array)
64
+
65
+ selected = signatures.each_with_index.map do |signature, index|
66
+ processed = processor(signature)
67
+ requested = only.flat_map do |path|
68
+ Pointer.tokens(path)
69
+ processed.paths.fetch(path) { raise ValidationError.new("Path is not selectively disclosable", pointer: path) }
70
+ end.uniq
71
+ entry = Codec.copy(signature)
72
+ entry["disclosures"] = signature.fetch("disclosures").select { |encoded| requested.include?(encoded) }
73
+ entry.delete("kb_jwt")
74
+ if key_binding && index.zero?
75
+ raise ArgumentError, "Key binding options must be a hash" unless key_binding.is_a?(Hash)
76
+
77
+ entry["kb_jwt"] = KeyBinding.create(compact(entry), hash_algorithm: processed.hash_algorithm, **key_binding)
78
+ end
79
+ entry
80
+ end
81
+ token = self.class.new(selected, limits: @limits)
82
+ serialized = selected.length == 1 && selected.first.fetch("unprotected").empty? ? token.to_s : token.to_json(format: :general)
83
+ SDJWT.parse(serialized, **@limits)
84
+ end
85
+
86
+ private
87
+
88
+ def compact(signature)
89
+ ([signature.fetch("jwt")] + signature.fetch("disclosures") + [signature.fetch("kb_jwt", "")]).join("~")
90
+ end
91
+
92
+ def processor(signature)
93
+ _, _, payload = Codec.jwt_parts(signature.fetch("jwt"), max_depth: @limits[:max_depth])
94
+ Processor.new(payload, signature.fetch("disclosures"), max_depth: @limits[:max_depth])
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ class Result
5
+ attr_reader :claims, :headers, :key_binding_verified
6
+
7
+ def initialize(claims:, headers:, key_binding_verified:)
8
+ @claims = Codec.freeze_tree(claims)
9
+ @headers = Codec.freeze_tree(headers)
10
+ @key_binding_verified = key_binding_verified
11
+ freeze
12
+ end
13
+ end
14
+
15
+ class Verifier
16
+ def initialize(algorithms:, issuer_key:, hash_algorithms: ["sha-256"], require_key_binding: false,
17
+ audience: nil, nonce: nil, max_kb_age: 300, key_binding_algorithms: %w[ES256 ES384 ES512 RS256 RS384 RS512 PS256 PS384 PS512],
18
+ required_claims: [], issuer: nil, leeway: 0, max_bytes: Codec::MAX_BYTES, max_depth: Codec::MAX_DEPTH,
19
+ max_disclosures: Codec::MAX_DISCLOSURES, max_signatures: Codec::MAX_SIGNATURES)
20
+ @algorithms = Codec.allowlist(algorithms)
21
+ @kb_algorithms = Codec.allowlist(key_binding_algorithms)
22
+ raise ArgumentError, "Holder signatures must be asymmetric" if @kb_algorithms.any? { |algorithm| algorithm.start_with?("HS") }
23
+ unless hash_algorithms.is_a?(Array) && !hash_algorithms.empty? && hash_algorithms.all? { |alg| HashAlg::ALGORITHMS.key?(alg) }
24
+ raise ArgumentError, "A nonempty supported hash algorithm allowlist is required"
25
+ end
26
+ raise ArgumentError, "Required claim names must be strings" unless required_claims.is_a?(Array) && required_claims.all?(String)
27
+ raise ArgumentError, "Invalid key binding policy" unless [true, false].include?(require_key_binding)
28
+ unless max_kb_age.is_a?(Numeric) && max_kb_age.finite? && max_kb_age.positive? && leeway.is_a?(Numeric) && leeway.finite? && leeway >= 0
29
+ raise ArgumentError, "Invalid time policy"
30
+ end
31
+ if require_key_binding && (!audience.is_a?(String) || audience.empty? || !nonce.is_a?(String) || nonce.empty?)
32
+ raise ArgumentError, "Key binding requires a nonempty audience and nonce"
33
+ end
34
+
35
+ @key = issuer_key
36
+ @hash_algorithms = hash_algorithms.map(&:dup).each(&:freeze).freeze
37
+ @require_key_binding = require_key_binding
38
+ @audience = audience
39
+ @nonce = nonce
40
+ @max_kb_age = max_kb_age
41
+ @required_claims = required_claims.map(&:dup).each(&:freeze).freeze
42
+ @issuer = issuer
43
+ @leeway = leeway
44
+ @limits = Codec.limits(max_bytes: max_bytes, max_depth: max_depth, max_disclosures: max_disclosures, max_signatures: max_signatures)
45
+ end
46
+
47
+ def verify(source)
48
+ token = split(source)
49
+ now = Time.now.to_f
50
+ results = token.signatures.each_with_index.map do |signature, index|
51
+ payload, header = verify_issuer_jwt(signature)
52
+ processed = Processor.new(payload, signature.fetch("disclosures"), max_depth: @limits[:max_depth], hash_algorithms: @hash_algorithms)
53
+ check_validity_claims(processed.claims, now)
54
+ if @require_key_binding && index.zero?
55
+ raise VerificationError, "Missing required key binding" unless signature["kb_jwt"]
56
+
57
+ KeyBinding.verify(signature, claims: processed.claims, hash_algorithm: processed.hash_algorithm, algorithms: @kb_algorithms,
58
+ audience: @audience, nonce: @nonce, max_age: @max_kb_age, now: now, max_depth: @limits[:max_depth])
59
+ end
60
+ [processed.claims, header]
61
+ end
62
+ raise VerificationError, "General JWS signatures disclose different claims" unless results.all? { |claims, _| claims == results.first[0] }
63
+
64
+ Result.new(claims: results.first[0], headers: results.map(&:last), key_binding_verified: @require_key_binding)
65
+ rescue ParseError, ValidationError, JWT::DecodeError, JWT::EncodeError
66
+ raise VerificationError, "Invalid SD-JWT", cause: nil
67
+ end
68
+
69
+ def self.check_times(claims, now, leeway = 0)
70
+ Codec.validate_claim_types(claims)
71
+ raise VerificationError, "Expired credential" if claims.key?("exp") && now >= claims["exp"] + leeway
72
+ raise VerificationError, "Credential is not yet valid" if claims.key?("nbf") && now + leeway < claims["nbf"]
73
+ end
74
+
75
+ private
76
+
77
+ # RFC 9901 section 7.1 step 1. Reparse Token inputs under this verifier's limits.
78
+ def split(source)
79
+ source = source.to_json(format: :general) if source.is_a?(Token)
80
+ SDJWT.parse(source, **@limits)
81
+ end
82
+
83
+ # RFC 9901 section 7.1 step 2. The callback is responsible for issuer/key trust.
84
+ def verify_issuer_jwt(signature)
85
+ _, header, payload = Codec.jwt_parts(signature.fetch("jwt"), max_depth: @limits[:max_depth])
86
+ merged_header = header.merge(signature.fetch("unprotected"))
87
+ raise VerificationError, "Signing algorithm is not allowed" unless @algorithms.include?(merged_header["alg"])
88
+
89
+ key = @key.respond_to?(:call) ? @key.call(Codec.freeze_tree(Codec.copy(merged_header)), Codec.freeze_tree(Codec.copy(payload))) : @key
90
+ Codec.verify_signature(signature.fetch("jwt"), key: key, algorithms: @algorithms,
91
+ unprotected: signature.fetch("unprotected"), max_depth: @limits[:max_depth])
92
+ end
93
+
94
+ # RFC 9901 section 7.1 step 6: check validity after selective disclosure.
95
+ def check_validity_claims(claims, now)
96
+ raise VerificationError, "Required validity claim missing" unless @required_claims.all? { |name| claims.key?(name) }
97
+ raise VerificationError, "Issuer mismatch" if @issuer && claims["iss"] != @issuer
98
+
99
+ if claims.key?("aud")
100
+ audience = claims["aud"]
101
+ audiences = audience.is_a?(String) ? [audience] : audience
102
+ unless audiences.is_a?(Array) && !audiences.empty? && audiences.all?(String) && @audience && audiences.include?(@audience)
103
+ raise VerificationError, "Audience mismatch"
104
+ end
105
+ end
106
+ self.class.check_times(claims, now, @leeway)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SDJWT
4
+ VERSION = "0.1.0"
5
+ end
6
+ SdJwt = SDJWT
data/lib/sd_jwt.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sd_jwt/version"
4
+ require_relative "sd_jwt/errors"
5
+ require_relative "sd_jwt/codec"
6
+ require_relative "sd_jwt/hash_alg"
7
+ require_relative "sd_jwt/disclosure"
8
+ require_relative "sd_jwt/pointer"
9
+ require_relative "sd_jwt/processor"
10
+ require_relative "sd_jwt/token"
11
+ require_relative "sd_jwt/serialization"
12
+ require_relative "sd_jwt/key_binding"
13
+ require_relative "sd_jwt/issuer"
14
+ require_relative "sd_jwt/verifier"
15
+
16
+ module SDJWT
17
+ # Parse holder input without authenticating it. Verification is always explicit.
18
+ def self.parse(source, max_bytes: Codec::MAX_BYTES, max_depth: Codec::MAX_DEPTH,
19
+ max_disclosures: Codec::MAX_DISCLOSURES, max_signatures: Codec::MAX_SIGNATURES, strict: true)
20
+ raise ArgumentError, "Cryptographic inputs require strict parsing" unless strict
21
+
22
+ limits = Codec.limits(max_bytes: max_bytes, max_depth: max_depth, max_disclosures: max_disclosures, max_signatures: max_signatures)
23
+ Serialization.parse(source, limits: limits)
24
+ end
25
+ end
data/sig/sd_jwt.rbs ADDED
@@ -0,0 +1,71 @@
1
+ module SDJWT
2
+ VERSION: String
3
+ type json = nil | bool | Integer | Float | String | Array[json] | Hash[String, json]
4
+ def self.parse: (String source, ?max_bytes: Integer, ?max_depth: Integer, ?max_disclosures: Integer, ?max_signatures: Integer, ?strict: bool) -> Token
5
+
6
+ class Error < StandardError
7
+ end
8
+ class ParseError < Error
9
+ end
10
+ class ValidationError < Error
11
+ attr_reader pointer: String
12
+ def initialize: (String message, ?pointer: String) -> void
13
+ end
14
+ class ConversionError < Error
15
+ end
16
+ class VerificationError < Error
17
+ end
18
+
19
+ class Disclosure
20
+ attr_reader encoded: String
21
+ attr_reader salt: String
22
+ attr_reader name: String?
23
+ attr_reader value: json
24
+ def self.parse: (String, ?max_depth: Integer) -> Disclosure
25
+ def initialize: (Array[json], ?encoded: String?, ?max_depth: Integer) -> void
26
+ def digest: (?String) -> String
27
+ def array_element?: () -> bool
28
+ def to_s: () -> String
29
+ end
30
+
31
+ module HashAlg
32
+ def self.digest: (String, ?String) -> String
33
+ end
34
+
35
+ class Issuer
36
+ def initialize: (signing_key: untyped, ?alg: String, ?hash_alg: String, ?max_bytes: Integer, ?max_depth: Integer, ?max_disclosures: Integer) -> void
37
+ def issue: (Hash[String, json], ?disclose: Array[String], ?decoys: Integer, ?holder_jwk: Hash[String, json]?, ?header: Hash[String, json]) -> Token
38
+ end
39
+
40
+ class Token
41
+ attr_reader signatures: Array[Hash[String, untyped]]
42
+ attr_reader warnings: Array[String]
43
+ def disclosures: () -> Array[String]
44
+ def issuer_jwt: () -> String
45
+ def key_binding_jwt: () -> String?
46
+ def unverified_claims: () -> Hash[String, json]
47
+ def disclosed_paths: () -> Array[String]
48
+ def to_s: () -> String
49
+ def to_json: (*untyped, ?format: :flattened | :general) -> String
50
+ def present: (only: Array[String], ?key_binding: Hash[Symbol, untyped]?) -> Token
51
+ end
52
+
53
+ class Result
54
+ attr_reader claims: Hash[String, json]
55
+ attr_reader headers: Array[Hash[String, json]]
56
+ attr_reader key_binding_verified: bool
57
+ end
58
+
59
+ class Verifier
60
+ def initialize: (algorithms: Array[String], issuer_key: untyped, ?hash_algorithms: Array[String], ?require_key_binding: bool,
61
+ ?audience: String?, ?nonce: String?, ?max_kb_age: Numeric, ?key_binding_algorithms: Array[String], ?required_claims: Array[String],
62
+ ?issuer: String?, ?leeway: Numeric, ?max_bytes: Integer, ?max_depth: Integer, ?max_disclosures: Integer, ?max_signatures: Integer) -> void
63
+ def verify: (String | Token) -> Result
64
+ end
65
+
66
+ module Testing
67
+ def self.with_salts: [T] (Array[String]) { () -> T } -> T
68
+ end
69
+ end
70
+
71
+ module SdJwt = SDJWT