openid4vp 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.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +278 -0
  5. data/lib/openid4vp/authorization_request/validator.rb +177 -0
  6. data/lib/openid4vp/authorization_request.rb +302 -0
  7. data/lib/openid4vp/authorization_response.rb +151 -0
  8. data/lib/openid4vp/client_id.rb +90 -0
  9. data/lib/openid4vp/credential.rb +51 -0
  10. data/lib/openid4vp/dc_api.rb +116 -0
  11. data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
  12. data/lib/openid4vp/dcql/claims_query.rb +76 -0
  13. data/lib/openid4vp/dcql/credential_query.rb +178 -0
  14. data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
  15. data/lib/openid4vp/dcql/evaluator.rb +228 -0
  16. data/lib/openid4vp/dcql/matcher.rb +79 -0
  17. data/lib/openid4vp/dcql/meta.rb +99 -0
  18. data/lib/openid4vp/dcql/query.rb +93 -0
  19. data/lib/openid4vp/dcql/selection.rb +45 -0
  20. data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
  21. data/lib/openid4vp/dcql.rb +10 -0
  22. data/lib/openid4vp/encoded_response.rb +10 -0
  23. data/lib/openid4vp/error_response.rb +82 -0
  24. data/lib/openid4vp/errors.rb +73 -0
  25. data/lib/openid4vp/formats/base.rb +74 -0
  26. data/lib/openid4vp/formats/context.rb +56 -0
  27. data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
  28. data/lib/openid4vp/formats/ldp_vc.rb +160 -0
  29. data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
  30. data/lib/openid4vp/formats/registry.rb +53 -0
  31. data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
  32. data/lib/openid4vp/formats/verified_presentation.rb +45 -0
  33. data/lib/openid4vp/formats.rb +12 -0
  34. data/lib/openid4vp/jose/algorithms.rb +46 -0
  35. data/lib/openid4vp/jose/jwe.rb +184 -0
  36. data/lib/openid4vp/jose/jwk.rb +253 -0
  37. data/lib/openid4vp/jose/jwk_set.rb +57 -0
  38. data/lib/openid4vp/jose/jws.rb +225 -0
  39. data/lib/openid4vp/jose.rb +8 -0
  40. data/lib/openid4vp/mdoc/builder.rb +132 -0
  41. data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
  42. data/lib/openid4vp/mdoc/cose_key.rb +71 -0
  43. data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
  44. data/lib/openid4vp/mdoc/device_response.rb +77 -0
  45. data/lib/openid4vp/mdoc/device_signed.rb +111 -0
  46. data/lib/openid4vp/mdoc/document.rb +44 -0
  47. data/lib/openid4vp/mdoc/handover.rb +51 -0
  48. data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
  49. data/lib/openid4vp/mdoc/mso.rb +133 -0
  50. data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
  51. data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
  52. data/lib/openid4vp/mdoc/verifier.rb +169 -0
  53. data/lib/openid4vp/mdoc.rb +9 -0
  54. data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
  55. data/lib/openid4vp/metadata/vp_formats.rb +112 -0
  56. data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
  57. data/lib/openid4vp/metadata.rb +10 -0
  58. data/lib/openid4vp/request_object.rb +152 -0
  59. data/lib/openid4vp/request_verification/base.rb +63 -0
  60. data/lib/openid4vp/request_verification/config.rb +65 -0
  61. data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
  62. data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
  63. data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
  64. data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
  65. data/lib/openid4vp/request_verification/resolver.rb +76 -0
  66. data/lib/openid4vp/request_verification/verified_request.rb +12 -0
  67. data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
  68. data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
  69. data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
  70. data/lib/openid4vp/request_verification.rb +10 -0
  71. data/lib/openid4vp/response_encryption.rb +51 -0
  72. data/lib/openid4vp/response_endpoint_result.rb +33 -0
  73. data/lib/openid4vp/response_mode.rb +44 -0
  74. data/lib/openid4vp/response_type.rb +7 -0
  75. data/lib/openid4vp/sd_jwt/digest.rb +30 -0
  76. data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
  77. data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
  78. data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
  79. data/lib/openid4vp/sd_jwt/parser.rb +48 -0
  80. data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
  81. data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
  82. data/lib/openid4vp/sd_jwt.rb +10 -0
  83. data/lib/openid4vp/transaction_data/registry.rb +42 -0
  84. data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
  85. data/lib/openid4vp/transaction_data.rb +138 -0
  86. data/lib/openid4vp/util/base64url.rb +44 -0
  87. data/lib/openid4vp/util/form.rb +24 -0
  88. data/lib/openid4vp/util/random.rb +29 -0
  89. data/lib/openid4vp/util/uri.rb +48 -0
  90. data/lib/openid4vp/util.rb +23 -0
  91. data/lib/openid4vp/verifier/config.rb +216 -0
  92. data/lib/openid4vp/verifier/created_request.rb +25 -0
  93. data/lib/openid4vp/verifier/http_result.rb +34 -0
  94. data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
  95. data/lib/openid4vp/verifier/request_builder.rb +216 -0
  96. data/lib/openid4vp/verifier/request_signer.rb +59 -0
  97. data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
  98. data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
  99. data/lib/openid4vp/verifier/response_validator.rb +258 -0
  100. data/lib/openid4vp/verifier/session.rb +109 -0
  101. data/lib/openid4vp/verifier/session_store.rb +48 -0
  102. data/lib/openid4vp/verifier/verified_response.rb +53 -0
  103. data/lib/openid4vp/verifier.rb +155 -0
  104. data/lib/openid4vp/verifier_attestation.rb +130 -0
  105. data/lib/openid4vp/verifier_info.rb +102 -0
  106. data/lib/openid4vp/version.rb +5 -0
  107. data/lib/openid4vp/vp_token.rb +81 -0
  108. data/lib/openid4vp/wallet/config.rb +49 -0
  109. data/lib/openid4vp/wallet/http_client.rb +73 -0
  110. data/lib/openid4vp/wallet/presenter.rb +90 -0
  111. data/lib/openid4vp/wallet/processed_request.rb +14 -0
  112. data/lib/openid4vp/wallet/request_processor.rb +237 -0
  113. data/lib/openid4vp/wallet/response_builder.rb +44 -0
  114. data/lib/openid4vp/wallet/submission.rb +68 -0
  115. data/lib/openid4vp/wallet.rb +142 -0
  116. data/lib/openid4vp/x509/chain_validator.rb +42 -0
  117. data/lib/openid4vp/x509.rb +103 -0
  118. data/lib/openid4vp.rb +18 -0
  119. metadata +233 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "verified_request"
5
+ require_relative "../errors"
6
+ require_relative "../x509"
7
+ require_relative "../x509/chain_validator"
8
+ require_relative "../jose/jwk"
9
+
10
+ module OpenID4VP
11
+ module RequestVerification
12
+ # `x509_san_dns` Client Identifier Prefix (OpenID4VP §5.9.3): the
13
+ # original Client Identifier is a DNS name that MUST match a dNSName
14
+ # Subject Alternative Name entry in the leaf certificate carried in the
15
+ # request's `x5c` JOSE header.
16
+ class X509SanDns < Base
17
+ def verify!(client_id, request, parsed, dc_api: false)
18
+ raise OpenID4VP::InvalidRequest, "signed request required" if parsed.nil?
19
+
20
+ certs = parsed.x5c_certs
21
+ raise OpenID4VP::InvalidRequest, "x5c required" if certs.empty?
22
+
23
+ leaf = OpenID4VP::X509::ChainValidator.new(
24
+ trust_anchors: config.x509_trust_anchors, time: config.current_time
25
+ ).validate!(certs)
26
+
27
+ unless OpenID4VP::X509.dns_sans(leaf).include?(client_id.original)
28
+ raise OpenID4VP::VerificationError, "SAN mismatch"
29
+ end
30
+
31
+ signing_key = OpenID4VP::JOSE::JWK.from_openssl(leaf.public_key)
32
+ verify_signature!(parsed, signing_key)
33
+ check_return_uri_host!(client_id, request, dc_api)
34
+
35
+ VerifiedRequest.new(
36
+ request: request, client_id: client_id, verifier_metadata: request.client_metadata,
37
+ signed: true, signing_key: signing_key, parsed: parsed, attestation: nil
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def check_return_uri_host!(client_id, request, dc_api)
44
+ return if dc_api || config.trusted_client_ids.include?(client_id.to_s)
45
+
46
+ return_uri = request.return_uri
47
+ return if return_uri.nil?
48
+
49
+ host = begin
50
+ ::URI.parse(return_uri).host
51
+ rescue ::URI::InvalidURIError
52
+ raise OpenID4VP::VerificationError, "malformed return URI"
53
+ end
54
+ return if host&.casecmp?(client_id.original)
55
+
56
+ raise OpenID4VP::VerificationError, "return URI host mismatch"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # Wallet-side request verification (OpenID4VP §5.9): one strategy per
5
+ # Client Identifier Prefix (`OpenID4VP::RequestVerification::Base`
6
+ # subclasses), dispatched by `OpenID4VP::RequestVerification::Resolver`
7
+ # according to `OpenID4VP::RequestVerification::Config`.
8
+ module RequestVerification
9
+ end
10
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "jose/jwe"
5
+
6
+ module OpenID4VP
7
+ # OpenID4VP §8.3: selects the Verifier's encryption key, `alg` and `enc`
8
+ # for an encrypted Authorization Response from the Verifier's
9
+ # `client_metadata` (a `Metadata::VerifierMetadata`), optionally narrowed
10
+ # by what the Wallet itself supports, and encrypts response payloads to it.
11
+ class ResponseEncryption
12
+ def initialize(verifier_metadata:, wallet_supported_algs: nil, wallet_supported_encs: nil)
13
+ @verifier_metadata = verifier_metadata
14
+ @wallet_supported_algs = wallet_supported_algs
15
+ @wallet_supported_encs = wallet_supported_encs
16
+ end
17
+
18
+ # The JWK to encrypt to. Raises OpenID4VP::InvalidRequest when the
19
+ # Verifier's metadata has no usable encryption key.
20
+ def key
21
+ return @key if @key
22
+
23
+ @key = @verifier_metadata.encryption_key(supported_algs: @wallet_supported_algs)
24
+ raise OpenID4VP::InvalidRequest, "no usable encryption key" if @key.nil?
25
+
26
+ @key
27
+ end
28
+
29
+ def alg
30
+ key.alg
31
+ end
32
+
33
+ # The JWE `enc` value: the first entry common to the Verifier's
34
+ # `encrypted_response_enc_values_supported` and the Wallet's supported
35
+ # `enc` values (all are considered supported when the latter is nil).
36
+ # Raises OpenID4VP::InvalidRequest when there is no common value.
37
+ def enc
38
+ return @enc if @enc
39
+
40
+ verifier_encs = @verifier_metadata.encrypted_response_enc_values_supported
41
+ common = @wallet_supported_encs ? (verifier_encs & @wallet_supported_encs) : verifier_encs
42
+ raise OpenID4VP::InvalidRequest, "no common content encryption algorithm" if common.empty?
43
+
44
+ @enc = common.first
45
+ end
46
+
47
+ def encrypt(payload_hash)
48
+ OpenID4VP::JOSE::JWE.encrypt(payload_hash, recipient: key, alg: alg, enc: enc)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # OpenID4VP §8.2: the JSON body a Response Endpoint returns to the Wallet
5
+ # (HTTP 200, application/json, Cache-Control: no-store) after processing a
6
+ # direct_post[.jwt] Authorization Response.
7
+ class ResponseEndpointResult
8
+ HEADERS = { "content-type" => "application/json", "cache-control" => "no-store" }.freeze
9
+
10
+ attr_reader :redirect_uri
11
+
12
+ def initialize(redirect_uri: nil)
13
+ @redirect_uri = redirect_uri
14
+ freeze
15
+ end
16
+
17
+ def status
18
+ 200
19
+ end
20
+
21
+ def headers
22
+ HEADERS
23
+ end
24
+
25
+ def to_h
26
+ @redirect_uri.nil? ? {} : { "redirect_uri" => @redirect_uri }
27
+ end
28
+
29
+ def to_json(*)
30
+ ::JSON.generate(to_h, *)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ module ResponseMode
5
+ FRAGMENT = "fragment"
6
+ QUERY = "query"
7
+ DIRECT_POST = "direct_post"
8
+ DIRECT_POST_JWT = "direct_post.jwt"
9
+ DC_API = "dc_api"
10
+ DC_API_JWT = "dc_api.jwt"
11
+
12
+ ALL = [FRAGMENT, QUERY, DIRECT_POST, DIRECT_POST_JWT, DC_API, DC_API_JWT].freeze
13
+
14
+ module_function
15
+
16
+ def direct_post?(mode)
17
+ return false if mode.nil?
18
+
19
+ [DIRECT_POST, DIRECT_POST_JWT].include?(mode)
20
+ end
21
+
22
+ def dc_api?(mode)
23
+ return false if mode.nil?
24
+
25
+ [DC_API, DC_API_JWT].include?(mode)
26
+ end
27
+
28
+ def encrypted?(mode)
29
+ return false if mode.nil?
30
+
31
+ mode.end_with?(".jwt")
32
+ end
33
+
34
+ def redirect?(mode)
35
+ return false if mode.nil?
36
+
37
+ [FRAGMENT, QUERY].include?(mode)
38
+ end
39
+
40
+ def default
41
+ FRAGMENT
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ module ResponseType
5
+ VP_TOKEN = "vp_token"
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sd_jwt"
4
+ require_relative "../errors"
5
+ require_relative "../util/base64url"
6
+
7
+ module OpenID4VP
8
+ module SdJwt
9
+ # SD-JWT `_sd_alg` digest algorithms (draft-ietf-oauth-selective-disclosure-jwt-22
10
+ # §5.1.1.2): sha-256 (the default), sha-384, sha-512.
11
+ module Digest
12
+ ALGS = {
13
+ "sha-256" => "SHA256",
14
+ "sha-384" => "SHA384",
15
+ "sha-512" => "SHA512"
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ # base64url(hash(str)), hashing the ASCII bytes of `str` verbatim (no
21
+ # base64url decoding first) with the OpenSSL digest named by `alg`.
22
+ def hash(alg, str)
23
+ digest_name = ALGS.fetch(alg) do
24
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: unsupported _sd_alg #{alg.inspect}"
25
+ end
26
+ Util::Base64url.encode(::OpenSSL::Digest.digest(digest_name, str))
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sd_jwt"
4
+ require_relative "digest"
5
+ require_relative "../errors"
6
+ require_relative "../util/base64url"
7
+
8
+ module OpenID4VP
9
+ module SdJwt
10
+ # A single SD-JWT disclosure (draft-ietf-oauth-selective-disclosure-jwt-22
11
+ # §5.2): base64url(JSON array). `[salt, name, value]` discloses an object
12
+ # property (`name` is a String); `[salt, value]` discloses an array
13
+ # element (`name` is nil).
14
+ class Disclosure
15
+ attr_reader :salt, :name, :value, :raw
16
+
17
+ def initialize(salt:, value:, name: nil, raw: nil)
18
+ @salt = salt
19
+ @name = name
20
+ @value = value
21
+ @raw = raw || self.class.encode(salt: salt, name: name, value: value)
22
+ freeze
23
+ end
24
+
25
+ class << self
26
+ # Parses a base64url-encoded disclosure, preserving `raw` verbatim.
27
+ # Raises PresentationInvalid on malformed input: invalid
28
+ # base64url/JSON, not a 2- or 3-element array, or a non-String
29
+ # salt/name.
30
+ def parse(raw)
31
+ array = decode_array(raw)
32
+
33
+ case array.size
34
+ when 3
35
+ salt, name, value = array
36
+ unless salt.is_a?(::String) && name.is_a?(::String)
37
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure salt and name must be strings"
38
+ end
39
+
40
+ new(salt: salt, name: name, value: value, raw: raw)
41
+ when 2
42
+ salt, value = array
43
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure salt must be a string" unless salt.is_a?(::String)
44
+
45
+ new(salt: salt, value: value, raw: raw)
46
+ else
47
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure must be a 2- or 3-element array"
48
+ end
49
+ end
50
+
51
+ # Builds a disclosure for an object property (`name` given) or an
52
+ # array element (`name` nil), generating a fresh 128-bit salt unless
53
+ # one is given.
54
+ def build(value:, name: nil, salt: nil)
55
+ new(salt: salt || random_salt, name: name, value: value)
56
+ end
57
+
58
+ def encode(salt:, name:, value:)
59
+ array = name.nil? ? [salt, value] : [salt, name, value]
60
+ Util::Base64url.encode(::JSON.generate(array))
61
+ end
62
+
63
+ def random_salt
64
+ Util::Base64url.encode(::SecureRandom.random_bytes(16))
65
+ end
66
+
67
+ private
68
+
69
+ def decode_array(raw)
70
+ array =
71
+ begin
72
+ Util::Base64url.decode_json(raw)
73
+ rescue OpenID4VP::InvalidRequest
74
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: invalid disclosure encoding"
75
+ end
76
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure must be a JSON array" unless array.is_a?(::Array)
77
+
78
+ array
79
+ end
80
+ end
81
+
82
+ # Whether this disclosure discloses an array element (as opposed to an
83
+ # object property).
84
+ def array_element?
85
+ name.nil?
86
+ end
87
+
88
+ # base64url(hash(raw)) -- the digest referenced by `_sd` arrays or by
89
+ # `{"..." => digest}` array items.
90
+ def digest(alg = "sha-256")
91
+ Digest.hash(alg, raw)
92
+ end
93
+
94
+ def ==(other)
95
+ other.is_a?(Disclosure) && raw == other.raw
96
+ end
97
+ alias eql? ==
98
+
99
+ def hash
100
+ raw.hash
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sd_jwt"
4
+ require_relative "disclosure"
5
+ require_relative "../errors"
6
+ require_relative "../util/base64url"
7
+ require_relative "../jose/jws"
8
+
9
+ module OpenID4VP
10
+ module SdJwt
11
+ # Issues an IETF SD-JWT VC (`dc+sd-jwt`): builds selectively disclosable
12
+ # claims (draft-ietf-oauth-selective-disclosure-jwt-22 §5.1) and signs
13
+ # the resulting Issuer-signed JWT.
14
+ module Issuer
15
+ module_function
16
+
17
+ # `disclosable` is an Array of claims-path-like Arrays (of String
18
+ # property names / Integer array indices, or ClaimsPathPointer),
19
+ # each addressing a single object property or array element to make
20
+ # selectively disclosable -- only the path's final component becomes a
21
+ # disclosure; intermediate objects/arrays are navigated as plain
22
+ # structure. Returns `"<issuer-jwt>~<disclosure 1>~...~"`.
23
+ def issue(claims:, key:, vct:, iss:, iat:, exp:, disclosable: [], holder_jwk: nil, alg: nil, x5c: nil,
24
+ decoy: 0, typ: "dc+sd-jwt")
25
+ payload = deep_dup(claims)
26
+ payload["vct"] = vct
27
+ payload["iss"] = iss
28
+ payload["iat"] = iat
29
+ payload["exp"] = exp
30
+ payload["_sd_alg"] = "sha-256"
31
+ payload["cnf"] = { "jwk" => holder_jwk.to_public.to_h } if holder_jwk
32
+
33
+ # Deepest paths first, so a disclosure that itself contains a nested
34
+ # selectively-disclosable property (e.g. both ["address"] and
35
+ # ["address", "street_address"] listed) sees the still-plain parent
36
+ # object before it is turned into its own disclosure.
37
+ ordered = disclosable.sort_by { |path| -path_components(path).size }
38
+ disclosures = ordered.flat_map { |path| apply_disclosure!(payload, path_components(path)) }
39
+
40
+ add_decoys!(payload, decoy) if decoy.positive?
41
+ sort_sd_arrays!(payload)
42
+
43
+ header = { "typ" => typ }
44
+ header["x5c"] = x5c if x5c
45
+
46
+ issuer_jwt = OpenID4VP::JOSE::JWS.sign(payload, key: key, alg: alg, header: header)
47
+ "#{issuer_jwt}~#{disclosures.map { |d| "#{d.raw}~" }.join}"
48
+ end
49
+
50
+ def path_components(path)
51
+ path.respond_to?(:components) ? path.components : path
52
+ end
53
+ private_class_method :path_components
54
+
55
+ def deep_dup(claims)
56
+ ::JSON.parse(::JSON.generate(claims))
57
+ end
58
+ private_class_method :deep_dup
59
+
60
+ def apply_disclosure!(payload, components)
61
+ if !components.is_a?(::Array) || components.empty?
62
+ raise OpenID4VP::InvalidRequest, "sd-jwt: disclosable path must be a non-empty array"
63
+ end
64
+
65
+ parent = components[0..-2].reduce(payload) { |node, component| navigate(node, component) }
66
+ build_disclosure!(parent, components.last)
67
+ end
68
+ private_class_method :apply_disclosure!
69
+
70
+ def navigate(node, component)
71
+ case component
72
+ when ::String
73
+ unless node.is_a?(::Hash) && node.key?(component)
74
+ raise OpenID4VP::InvalidRequest, "sd-jwt: no such claim #{component.inspect} to navigate"
75
+ end
76
+
77
+ node[component]
78
+ when ::Integer
79
+ unless node.is_a?(::Array) && component < node.size
80
+ raise OpenID4VP::InvalidRequest, "sd-jwt: no such index #{component} to navigate"
81
+ end
82
+
83
+ node[component]
84
+ else
85
+ raise OpenID4VP::InvalidRequest, "sd-jwt: unsupported claims path component #{component.inspect}"
86
+ end
87
+ end
88
+ private_class_method :navigate
89
+
90
+ def build_disclosure!(parent, final)
91
+ case final
92
+ when ::String
93
+ unless parent.is_a?(::Hash) && parent.key?(final)
94
+ raise OpenID4VP::InvalidRequest, "sd-jwt: no such claim #{final.inspect} to disclose"
95
+ end
96
+
97
+ disclosure = Disclosure.build(name: final, value: parent.delete(final))
98
+ (parent["_sd"] ||= []) << disclosure.digest
99
+ disclosure
100
+ when ::Integer
101
+ unless parent.is_a?(::Array) && final < parent.size
102
+ raise OpenID4VP::InvalidRequest, "sd-jwt: no such index #{final} to disclose"
103
+ end
104
+
105
+ disclosure = Disclosure.build(value: parent[final])
106
+ parent[final] = { "..." => disclosure.digest }
107
+ disclosure
108
+ else
109
+ raise OpenID4VP::InvalidRequest, "sd-jwt: unsupported claims path component #{final.inspect}"
110
+ end
111
+ end
112
+ private_class_method :build_disclosure!
113
+
114
+ def add_decoys!(payload, count)
115
+ payload["_sd"] ||= []
116
+ count.times { payload["_sd"] << Util::Base64url.encode(::SecureRandom.random_bytes(32)) }
117
+ end
118
+ private_class_method :add_decoys!
119
+
120
+ def sort_sd_arrays!(node)
121
+ case node
122
+ when ::Hash
123
+ node["_sd"] = node["_sd"].sort if node["_sd"].is_a?(::Array)
124
+ node.each_value { |value| sort_sd_arrays!(value) }
125
+ when ::Array
126
+ node.each { |value| sort_sd_arrays!(value) }
127
+ end
128
+ end
129
+ private_class_method :sort_sd_arrays!
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sd_jwt"
4
+ require_relative "../errors"
5
+ require_relative "../util"
6
+ require_relative "../jose/jws"
7
+ require_relative "../jose/algorithms"
8
+
9
+ module OpenID4VP
10
+ module SdJwt
11
+ # The SD-JWT Key Binding JWT (draft-ietf-oauth-selective-disclosure-jwt-22
12
+ # §4.3): proves possession of the key in the Issuer-signed JWT's
13
+ # `cnf.jwk`, binding the presentation to a `nonce`/`aud` and to the exact
14
+ # disclosures presented (`sd_hash`).
15
+ module KbJwt
16
+ TYP = "kb+jwt"
17
+
18
+ module_function
19
+
20
+ # Signs a Key Binding JWT with `key` (the holder's private key). `iat`
21
+ # defaults to `Time.now.to_i`; `extra` claims (e.g.
22
+ # `transaction_data_hashes`) are merged in.
23
+ def build(aud:, nonce:, sd_hash:, key:, alg: nil, iat: nil, extra: {})
24
+ payload = { "iat" => iat || Time.now.to_i, "aud" => aud, "nonce" => nonce, "sd_hash" => sd_hash }.merge(extra)
25
+ OpenID4VP::JOSE::JWS.sign(payload, key: key, alg: alg, header: { "typ" => TYP })
26
+ end
27
+
28
+ # Verifies a Key Binding JWT against `key` (the holder's public key
29
+ # from the Issuer-signed JWT's `cnf.jwk`): `typ`, signature, `aud`,
30
+ # `nonce`, `sd_hash` and `iat` (within `leeway` seconds of `now`).
31
+ # Raises HolderBindingInvalid on any failure. Returns the payload Hash.
32
+ def verify(compact, key:, expected_aud:, expected_nonce:, expected_sd_hash:, now: Time.now, leeway: 60,
33
+ algorithms: OpenID4VP::JOSE::Algorithms::SIGNING)
34
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: missing" if compact.nil? || compact.empty?
35
+
36
+ verified = verify_signature(compact, key, algorithms)
37
+ check_typ!(verified.header)
38
+ payload = verified.payload
39
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: payload is not a JSON object" unless payload.is_a?(::Hash)
40
+
41
+ check_claims!(payload, expected_aud, expected_nonce, expected_sd_hash)
42
+ check_iat!(payload["iat"], now, leeway)
43
+
44
+ payload
45
+ end
46
+
47
+ def verify_signature(compact, key, algorithms)
48
+ OpenID4VP::JOSE::JWS.verify(compact, key: key, algorithms: algorithms)
49
+ rescue OpenID4VP::InvalidRequest, OpenID4VP::VerificationError => e
50
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: #{e.message}"
51
+ end
52
+ private_class_method :verify_signature
53
+
54
+ def check_typ!(header)
55
+ return if header["typ"] == TYP
56
+
57
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: unexpected typ #{header['typ'].inspect}"
58
+ end
59
+ private_class_method :check_typ!
60
+
61
+ def check_claims!(payload, expected_aud, expected_nonce, expected_sd_hash)
62
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: aud mismatch" unless payload["aud"] == expected_aud
63
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: nonce mismatch" unless payload["nonce"] == expected_nonce
64
+
65
+ sd_hash = payload["sd_hash"]
66
+ return if sd_hash.is_a?(::String) && OpenID4VP::Util.secure_compare(sd_hash, expected_sd_hash)
67
+
68
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: sd_hash mismatch"
69
+ end
70
+ private_class_method :check_claims!
71
+
72
+ def check_iat!(iat, now, leeway)
73
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: missing iat" unless iat.is_a?(::Integer)
74
+
75
+ return if (iat - now.to_i).abs <= leeway
76
+
77
+ raise OpenID4VP::HolderBindingInvalid, "kb-jwt: iat outside of allowed leeway"
78
+ end
79
+ private_class_method :check_iat!
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sd_jwt"
4
+ require_relative "disclosure"
5
+ require_relative "digest"
6
+ require_relative "../errors"
7
+
8
+ module OpenID4VP
9
+ module SdJwt
10
+ # Splits an SD-JWT(+KB) compact serialization
11
+ # (`<Issuer-signed JWT>~<Disclosure 1>~...~<Disclosure N>~<KB-JWT>`, the
12
+ # KB-JWT being optional) into its parts, per
13
+ # draft-ietf-oauth-selective-disclosure-jwt-22 §4.
14
+ module Parser
15
+ # `raw` is the exact string parsed. `kb_jwt` is nil when the input has
16
+ # no Key Binding JWT (the segment after the last "~" is empty).
17
+ Parsed = Struct.new(:issuer_jwt, :disclosures, :kb_jwt, :raw, keyword_init: true) do
18
+ # The presented string up to and including the last disclosure's
19
+ # trailing "~", omitting any Key Binding JWT -- the exact string a
20
+ # Key Binding JWT's `sd_hash` is computed over.
21
+ def without_kb
22
+ "#{issuer_jwt}~#{disclosures.map { |d| "#{d.raw}~" }.join}"
23
+ end
24
+
25
+ def sd_hash(alg = "sha-256")
26
+ Digest.hash(alg, without_kb)
27
+ end
28
+ end
29
+
30
+ module_function
31
+
32
+ # Raises PresentationInvalid unless `str` is a String containing at
33
+ # least one "~".
34
+ def parse(str)
35
+ unless str.is_a?(::String) && str.include?("~")
36
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: malformed presentation (missing '~')"
37
+ end
38
+
39
+ parts = str.split("~", -1)
40
+ issuer_jwt = parts.first
41
+ kb_jwt = parts.last.empty? ? nil : parts.last
42
+ disclosures = parts[1..-2].map { |part| Disclosure.parse(part) }
43
+
44
+ Parsed.new(issuer_jwt: issuer_jwt, disclosures: disclosures, kb_jwt: kb_jwt, raw: str)
45
+ end
46
+ end
47
+ end
48
+ end