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
|
@@ -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
|
data/lib/sd_jwt/token.rb
ADDED
|
@@ -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
|
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
|