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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "response_mode"
4
+ require_relative "errors"
5
+ require_relative "util/uri"
6
+ require_relative "util/form"
7
+ require_relative "encoded_response"
8
+
9
+ module OpenID4VP
10
+ # OpenID4VP §8.5: the Authorization Error Response. Per §8.3.1, error
11
+ # responses are always sent unencrypted, even for `.jwt` Response Modes.
12
+ class ErrorResponse
13
+ Encoded = EncodedResponse
14
+
15
+ attr_reader :error, :error_description, :state
16
+
17
+ def initialize(error:, error_description: nil, state: nil)
18
+ @error = error
19
+ @error_description = error_description
20
+ @state = state
21
+ freeze
22
+ end
23
+
24
+ # Builds an ErrorResponse from a raised OpenID4VP::Error (or any
25
+ # exception): `error` is `exception.code` when present, else
26
+ # "invalid_request"; `error_description` is `exception.description` when
27
+ # the exception responds to it, else `exception.message`.
28
+ def self.from_exception(exception, state: nil)
29
+ code = exception.respond_to?(:code) && exception.code ? exception.code : "invalid_request"
30
+ description = exception.respond_to?(:description) ? exception.description : exception.message
31
+ new(error: code, error_description: description, state: state)
32
+ end
33
+
34
+ def to_params
35
+ params = { "error" => @error }
36
+ params["error_description"] = @error_description unless @error_description.nil?
37
+ params.merge(state_param)
38
+ end
39
+ alias to_payload to_params
40
+
41
+ # Encodes this error response for `response_mode`. Never encrypts, even
42
+ # for `.jwt` Response Modes (OpenID4VP §8.3.1): those are treated the
43
+ # same as their unencrypted counterpart. `encryption` is accepted for
44
+ # signature parity with AuthorizationResponse#encode and ignored.
45
+ def encode(response_mode, redirect_uri: nil, encryption: nil) # rubocop:disable Lint/UnusedMethodArgument
46
+ validate_response_mode!(response_mode)
47
+ return encode_redirect(response_mode, redirect_uri) if ResponseMode.redirect?(response_mode)
48
+
49
+ if ResponseMode.direct_post?(response_mode)
50
+ Encoded.new(mode: response_mode, form_params: to_params, form_body: Util::Form.encode(to_params))
51
+ else
52
+ Encoded.new(mode: response_mode, data: to_payload)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def state_param
59
+ @state.nil? ? {} : { "state" => @state }
60
+ end
61
+
62
+ def validate_response_mode!(response_mode)
63
+ return if ResponseMode::ALL.include?(response_mode)
64
+
65
+ raise OpenID4VP::ConfigurationError, "unsupported response_mode #{response_mode.inspect}"
66
+ end
67
+
68
+ def encode_redirect(response_mode, redirect_uri)
69
+ if redirect_uri.nil?
70
+ raise OpenID4VP::ConfigurationError, "redirect_uri is required for response_mode #{response_mode}"
71
+ end
72
+
73
+ url =
74
+ if response_mode == ResponseMode::FRAGMENT
75
+ Util::URI.with_fragment(redirect_uri, to_params)
76
+ else
77
+ Util::URI.with_query(redirect_uri, to_params)
78
+ end
79
+ Encoded.new(mode: response_mode, url: url)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # Base class for all errors raised by this gem. Subclasses that represent
5
+ # OAuth 2.0 / OpenID4VP protocol errors declare an `oauth_code`; a subclass
6
+ # without its own code inherits its superclass's code.
7
+ class Error < StandardError
8
+ class << self
9
+ def oauth_code(code)
10
+ @code = code
11
+ end
12
+
13
+ def code
14
+ @code || (superclass.respond_to?(:code) ? superclass.code : nil)
15
+ end
16
+ end
17
+
18
+ def code
19
+ self.class.code
20
+ end
21
+
22
+ def description
23
+ message
24
+ end
25
+ end
26
+
27
+ class InvalidRequest < Error
28
+ oauth_code "invalid_request"
29
+ end
30
+
31
+ class InvalidClient < Error
32
+ oauth_code "invalid_client"
33
+ end
34
+
35
+ class InvalidScope < Error
36
+ oauth_code "invalid_scope"
37
+ end
38
+
39
+ class AccessDenied < Error
40
+ oauth_code "access_denied"
41
+ end
42
+
43
+ class VpFormatsNotSupported < Error
44
+ oauth_code "vp_formats_not_supported"
45
+ end
46
+
47
+ class InvalidRequestUriMethod < Error
48
+ oauth_code "invalid_request_uri_method"
49
+ end
50
+
51
+ class InvalidTransactionData < Error
52
+ oauth_code "invalid_transaction_data"
53
+ end
54
+
55
+ class WalletUnavailable < Error
56
+ oauth_code "wallet_unavailable"
57
+ end
58
+
59
+ # Raised for verifier-side response/presentation verification failures that
60
+ # are reported to the wallet as an OAuth "invalid_request" error.
61
+ class VerificationError < InvalidRequest; end
62
+
63
+ # Raised for wallet-side response validation problems. Has no OAuth error
64
+ # code of its own since it is not sent back over the wire as such.
65
+ class InvalidResponse < Error; end
66
+
67
+ class PresentationInvalid < InvalidResponse; end
68
+ class HolderBindingInvalid < InvalidResponse; end
69
+ class QueryNotSatisfied < InvalidResponse; end
70
+
71
+ class SessionNotFound < Error; end
72
+ class ConfigurationError < Error; end
73
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formats"
4
+ require_relative "../errors"
5
+ require_relative "../dcql/meta"
6
+
7
+ module OpenID4VP
8
+ module Formats
9
+ # Abstract base for a Credential Format handler. A concrete subclass
10
+ # implements `.format_id`, `#verify_presentation`, `#build_presentation`
11
+ # and `.credential_from`; `#check_vp_formats!` is overridden by formats
12
+ # that define `vp_formats_supported` algorithm-restriction parameters
13
+ # (OpenID4VP Appendix B).
14
+ class Base
15
+ def self.format_id
16
+ raise NotImplementedError, "#{self} must implement .format_id"
17
+ end
18
+
19
+ def self.credential_from(*, **)
20
+ raise NotImplementedError, "#{self} must implement .credential_from"
21
+ end
22
+
23
+ def format_id
24
+ self.class.format_id
25
+ end
26
+
27
+ # Verifies a single raw Presentation (a String, or a format-specific
28
+ # structure) against `context` (a `Context`), returning a
29
+ # `VerifiedPresentation`. Raises `PresentationInvalid` /
30
+ # `HolderBindingInvalid` on failure.
31
+ def verify_presentation(_presentation, _context)
32
+ raise NotImplementedError, "#{self.class} must implement #verify_presentation"
33
+ end
34
+
35
+ # Builds a raw Presentation (a String, or a format-specific Hash) for
36
+ # `credential` (an `OpenID4VP::Credential`), disclosing the claims
37
+ # addressed by `claims_queries` (an Array of `DCQL::ClaimsQuery`),
38
+ # against `context` (a `Context`).
39
+ def build_presentation(_credential, _claims_queries, _context)
40
+ raise NotImplementedError, "#{self.class} must implement #build_presentation"
41
+ end
42
+
43
+ # OpenID4VP Appendix B: validates the REQUIRED shape of this format's
44
+ # `meta` parameter in a DCQL Credential Query.
45
+ def validate_meta!(meta)
46
+ OpenID4VP::DCQL::Meta.validate!(format_id, meta)
47
+ end
48
+
49
+ # Whether `credential` (an `OpenID4VP::Credential`) matches `meta`.
50
+ def meta_matches?(meta, credential)
51
+ OpenID4VP::DCQL::Meta.matches?(format_id, meta, credential)
52
+ end
53
+
54
+ # Enforces this format's `vp_formats_supported` algorithm
55
+ # restrictions (OpenID4VP Appendix B) against a `VerifiedPresentation`.
56
+ # No-op by default: `vp_formats` is nil, or carries no parameters for
57
+ # this format. Formats with restrictable algorithms override this,
58
+ # using `alg_allowed!` below.
59
+ def check_vp_formats!(_verified, _vp_formats); end
60
+
61
+ protected
62
+
63
+ # Raises PresentationInvalid unless `alg` is allowed by `values` (an
64
+ # Array from `vp_formats.params_for(format_id)`, or nil when the
65
+ # parameter is absent). A nil `alg` (the restriction does not apply to
66
+ # this Presentation) is always allowed.
67
+ def alg_allowed!(values, alg, param_name)
68
+ return if values.nil? || alg.nil? || values.include?(alg)
69
+
70
+ raise OpenID4VP::PresentationInvalid, "#{param_name} does not allow #{alg}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formats"
4
+
5
+ module OpenID4VP
6
+ module Formats
7
+ # Everything a `Base` handler needs to verify or build a Presentation,
8
+ # threaded through by the (not-yet-implemented) protocol layer.
9
+ # Immutable.
10
+ class Context
11
+ attr_reader :nonce, :audience, :client_id, :response_uri, :origin, :encryption_jwk, :transaction_data, :now,
12
+ :leeway, :trust, :vp_formats, :require_holder_binding, :dc_api
13
+
14
+ # nonce/audience: the Authorization Request's `nonce`, and the
15
+ # expected `aud` of a holder-bound Presentation (the Client
16
+ # Identifier, or `origin:<origin>` for DC API requests -- see
17
+ # OpenID4VP Appendix B.3.6 / A.4).
18
+ # trust: a Hash with Symbol keys, e.g. `:sd_jwt_issuer_keys` (callable
19
+ # `(iss, header) -> JWK | [JWK]`), `:jwt_vc_issuer_keys`,
20
+ # `:jwt_vc_holder_keys`, `:mdoc_trust_anchors` (Array of
21
+ # certificates), `:ldp_proof_verifier` (callable
22
+ # `(vp_hash, context) -> bool`). Consulted via `#trust_hook`.
23
+ # `:authority_hooks` (OpenID4VP §6.1.1, consulted by
24
+ # `Verifier::ResponseValidator`, not via `#trust_hook`) is instead a
25
+ # Hash of `{trusted_authorities "type" String => callable(ta_query,
26
+ # credential) -> bool}`, for authority types `DCQL::Matcher
27
+ # .authority_matches?` has no built-in check for (only `"aki"` is
28
+ # built in).
29
+ # vp_formats: an `OpenID4VP::Metadata::VpFormats`, or nil to skip
30
+ # `Base#check_vp_formats!` algorithm restriction checks.
31
+ def initialize(nonce:, audience:, client_id: nil, response_uri: nil, origin: nil, encryption_jwk: nil,
32
+ transaction_data: nil, now: Time.now, leeway: 60, trust: {}, vp_formats: nil,
33
+ require_holder_binding: true, dc_api: false)
34
+ @nonce = nonce
35
+ @audience = audience
36
+ @client_id = client_id
37
+ @response_uri = response_uri
38
+ @origin = origin
39
+ @encryption_jwk = encryption_jwk
40
+ @transaction_data = transaction_data
41
+ @now = now
42
+ @leeway = leeway
43
+ @trust = trust.dup.freeze
44
+ @vp_formats = vp_formats
45
+ @require_holder_binding = require_holder_binding
46
+ @dc_api = dc_api
47
+ freeze
48
+ end
49
+
50
+ # The configured trust hook for `name` (a Symbol), or nil.
51
+ def trust_hook(name)
52
+ trust[name]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formats"
4
+ require_relative "base"
5
+ require_relative "verified_presentation"
6
+ require_relative "registry"
7
+ require_relative "../errors"
8
+ require_relative "../credential"
9
+ require_relative "../x509"
10
+ require_relative "../jose/jws"
11
+ require_relative "../jose/jwk"
12
+
13
+ module OpenID4VP
14
+ module Formats
15
+ # OpenID4VP Appendix B.1.3.1: the `jwt_vc_json` (W3C Verifiable
16
+ # Credential secured as a JWT, no JSON-LD) Credential Format handler.
17
+ class JwtVcJson < Base
18
+ FORMAT_ID = "jwt_vc_json"
19
+ VP_CONTEXT = ["https://www.w3.org/2018/credentials/v1"].freeze
20
+
21
+ def self.format_id
22
+ FORMAT_ID
23
+ end
24
+
25
+ # Issues a `jwt_vc_json` Verifiable Credential JWT with payload
26
+ # `{iss, sub, nbf, exp, jti, vc: {"@context", "type", "credentialSubject"}}`.
27
+ def self.issue_vc(claims:, issuer:, subject:, types:, key:, alg: nil, exp: nil, nbf: nil, jti: nil)
28
+ payload = {
29
+ "iss" => issuer,
30
+ "sub" => subject,
31
+ "nbf" => nbf || Time.now.to_i,
32
+ "jti" => jti || "urn:uuid:#{::SecureRandom.uuid}",
33
+ "vc" => {
34
+ "@context" => VP_CONTEXT,
35
+ "type" => types,
36
+ "credentialSubject" => claims
37
+ }
38
+ }
39
+ payload["exp"] = exp unless exp.nil?
40
+ OpenID4VP::JOSE::JWS.sign(payload, key: key, alg: alg, header: { "typ" => "JWT" })
41
+ end
42
+
43
+ # Parses (WITHOUT verifying the signature) an issued VC JWT into a
44
+ # wallet-side `OpenID4VP::Credential`, e.g. right after issuance.
45
+ def self.credential_from(vc_jwt, holder_key: nil)
46
+ peeked = OpenID4VP::JOSE::JWS.peek(vc_jwt)
47
+ claims, metadata = claims_and_metadata(peeked.payload)
48
+
49
+ OpenID4VP::Credential.new(
50
+ format: FORMAT_ID,
51
+ claims: claims,
52
+ raw: vc_jwt,
53
+ holder_key: holder_key,
54
+ x5chain: x5chain_from_header(peeked.header),
55
+ metadata: metadata
56
+ )
57
+ end
58
+
59
+ # Merges the VC's own claims (`vc`) with the enclosing JWT's top-level
60
+ # `iss`/`sub`/`nbf`/`exp`/`jti`, so both `["credentialSubject", "x"]`
61
+ # and `["iss"]` claims paths resolve. Returns `[claims, metadata]`.
62
+ def self.claims_and_metadata(payload)
63
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload is not a JSON object" unless payload.is_a?(::Hash)
64
+
65
+ vc = payload["vc"]
66
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload missing vc" unless vc.is_a?(::Hash)
67
+
68
+ claims = vc.merge(
69
+ "iss" => payload["iss"], "sub" => payload["sub"], "nbf" => payload["nbf"],
70
+ "exp" => payload["exp"], "jti" => payload["jti"]
71
+ ).compact
72
+ metadata = { "type" => vc["type"], "issuer" => payload["iss"], "holder" => payload["sub"] }
73
+ [claims, metadata]
74
+ end
75
+
76
+ def self.x5chain_from_header(header)
77
+ x5c = header["x5c"]
78
+ return [] unless x5c.is_a?(::Array)
79
+
80
+ OpenID4VP::X509.certs_from_x5c(x5c)
81
+ end
82
+ private_class_method :x5chain_from_header
83
+
84
+ # Verifies `presentation` (a compact JWT String) against `context`.
85
+ # Accepts either a Verifiable Presentation JWT (payload has `vp`) or,
86
+ # when `!context.require_holder_binding`, a bare Verifiable Credential
87
+ # JWT (payload has `vc` but no `vp`) -- OpenID4VP Appendix B.1.
88
+ def verify_presentation(presentation, context)
89
+ unless presentation.is_a?(::String)
90
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: presentation must be a string"
91
+ end
92
+
93
+ peeked = OpenID4VP::JOSE::JWS.peek(presentation)
94
+ payload = peeked.payload
95
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload is not a JSON object" unless payload.is_a?(::Hash)
96
+
97
+ if payload.key?("vp")
98
+ verify_vp(presentation, peeked.header, payload, context)
99
+ elsif payload.key?("vc")
100
+ verify_bare_vc(presentation, context)
101
+ else
102
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload has neither vp nor vc"
103
+ end
104
+ end
105
+
106
+ # OpenID4VP Appendix B.1.3.1.3: `alg_values` restricts the `alg` of
107
+ # both the Verifiable Presentation JWT and the Verifiable Credential
108
+ # JWT it carries.
109
+ def check_vp_formats!(verified, vp_formats)
110
+ return if vp_formats.nil?
111
+
112
+ params = vp_formats.params_for(FORMAT_ID)
113
+ return if params.nil?
114
+
115
+ alg_allowed!(params["alg_values"], verified.details[:vp_alg], "alg_values")
116
+ alg_allowed!(params["alg_values"], verified.details[:vc_alg], "alg_values")
117
+ end
118
+
119
+ # Builds a Verifiable Presentation JWT for `credential`. When the
120
+ # credential has no holder key: returns `credential.raw` unchanged if
121
+ # `!context.require_holder_binding` (a Credential without Holder
122
+ # Binding, Appendix B.1), else raises HolderBindingInvalid.
123
+ def build_presentation(credential, _claims_queries, context)
124
+ holder_key = credential.holder_key
125
+ if holder_key.nil?
126
+ raise OpenID4VP::HolderBindingInvalid, "credential has no holder key" if context.require_holder_binding
127
+
128
+ return credential.raw
129
+ end
130
+
131
+ iat = context.now.to_i
132
+ holder_id = credential.metadata["holder"] || "did:jwk:#{holder_key.thumbprint_b64}"
133
+ payload = {
134
+ "iss" => holder_id,
135
+ "jti" => "urn:uuid:#{::SecureRandom.uuid}",
136
+ "aud" => context.audience,
137
+ "nonce" => context.nonce,
138
+ "iat" => iat,
139
+ "nbf" => iat,
140
+ "exp" => iat + 300,
141
+ "vp" => {
142
+ "@context" => VP_CONTEXT,
143
+ "type" => ["VerifiablePresentation"],
144
+ "verifiableCredential" => [credential.raw]
145
+ }
146
+ }
147
+ OpenID4VP::JOSE::JWS.sign(payload, key: holder_key, header: { "typ" => "JWT" })
148
+ end
149
+
150
+ private
151
+
152
+ def verify_vp(presentation, header, unverified_payload, context)
153
+ resolver = context.trust_hook(:jwt_vc_holder_keys)
154
+ holder_key = resolver&.call(unverified_payload["iss"], header)
155
+ raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: cannot resolve holder key" if holder_key.nil?
156
+
157
+ verified = OpenID4VP::JOSE::JWS.verify(presentation, key: holder_key)
158
+ payload = verified.payload
159
+
160
+ raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: nonce mismatch" unless payload["nonce"] == context.nonce
161
+ raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: aud mismatch" unless payload["aud"] == context.audience
162
+
163
+ check_time_bounds!(payload, context, "presentation")
164
+
165
+ vc_jwt = single_verifiable_credential(payload["vp"])
166
+ vc_result = verify_vc_jwt(vc_jwt, context)
167
+ check_subject_binding!(payload["iss"], holder_key, vc_result[:payload])
168
+
169
+ VerifiedPresentation.new(
170
+ format: FORMAT_ID,
171
+ claims: vc_result[:claims],
172
+ holder_bound: true,
173
+ issuer: vc_result[:issuer],
174
+ raw: presentation,
175
+ metadata: vc_result[:metadata],
176
+ details: { vp_alg: verified.header["alg"], vc_alg: vc_result[:alg] }
177
+ )
178
+ end
179
+
180
+ def verify_bare_vc(presentation, context)
181
+ raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: holder binding required" if context.require_holder_binding
182
+
183
+ vc_result = verify_vc_jwt(presentation, context)
184
+
185
+ VerifiedPresentation.new(
186
+ format: FORMAT_ID,
187
+ claims: vc_result[:claims],
188
+ holder_bound: false,
189
+ issuer: vc_result[:issuer],
190
+ raw: presentation,
191
+ metadata: vc_result[:metadata],
192
+ details: { vp_alg: nil, vc_alg: vc_result[:alg] }
193
+ )
194
+ end
195
+
196
+ def single_verifiable_credential(vp_object)
197
+ vcs = vp_object.is_a?(::Hash) ? vp_object["verifiableCredential"] : nil
198
+ unless vcs.is_a?(::Array) && vcs.size == 1
199
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: multiple credentials in one presentation not supported"
200
+ end
201
+
202
+ vcs.first
203
+ end
204
+
205
+ def verify_vc_jwt(vc_jwt, context)
206
+ unless vc_jwt.is_a?(::String)
207
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: verifiableCredential entry must be a string"
208
+ end
209
+
210
+ peeked = OpenID4VP::JOSE::JWS.peek(vc_jwt)
211
+ vc_payload = peeked.payload
212
+ unless vc_payload.is_a?(::Hash)
213
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: vc payload is not a JSON object"
214
+ end
215
+
216
+ resolver = context.trust_hook(:jwt_vc_issuer_keys)
217
+ raise OpenID4VP::ConfigurationError, "jwt_vc_issuer_keys trust hook required" if resolver.nil?
218
+
219
+ issuer_key = resolver.call(vc_payload["iss"], peeked.header)
220
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: unknown issuer" if issuer_key.nil?
221
+
222
+ verified_vc = OpenID4VP::JOSE::JWS.verify(vc_jwt, key: issuer_key)
223
+ check_time_bounds!(verified_vc.payload, context, "credential")
224
+
225
+ claims, metadata = self.class.claims_and_metadata(verified_vc.payload)
226
+ { claims: claims, metadata: metadata, issuer: verified_vc.payload["iss"], alg: verified_vc.header["alg"],
227
+ payload: verified_vc.payload }
228
+ end
229
+
230
+ # OpenID4VP Appendix B.1.3.1.5: the VP JWT's `iss` (the holder id the
231
+ # presentation was signed under) must be the enclosed VC's own
232
+ # subject -- `iss == vc_payload["sub"]` or `iss ==
233
+ # vc["credentialSubject"]["id"]` -- so a party holding someone else's
234
+ # VC cannot present it as their own. Only when the VC declares
235
+ # neither does a `cnf.jwk` in the VC payload matching the VP signing
236
+ # key's thumbprint satisfy the binding instead.
237
+ def check_subject_binding!(vp_iss, vp_signing_key, vc_payload)
238
+ sub = vc_payload["sub"]
239
+ vc = vc_payload["vc"]
240
+ subject_id = vc.is_a?(::Hash) ? vc.dig("credentialSubject", "id") : nil
241
+
242
+ if sub || subject_id
243
+ return if vp_iss == sub || vp_iss == subject_id
244
+ elsif cnf_jwk_matches?(vc_payload, vp_signing_key)
245
+ return
246
+ end
247
+
248
+ raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: VP issuer does not match credential subject"
249
+ end
250
+
251
+ def cnf_jwk_matches?(vc_payload, vp_signing_key)
252
+ cnf_jwk_hash = vc_payload.dig("cnf", "jwk")
253
+ return false unless cnf_jwk_hash.is_a?(::Hash)
254
+
255
+ OpenID4VP::JOSE::JWK.new(cnf_jwk_hash).thumbprint == vp_signing_key.thumbprint
256
+ rescue OpenID4VP::InvalidRequest
257
+ false
258
+ end
259
+
260
+ def check_time_bounds!(payload, context, kind)
261
+ now = context.now.to_i
262
+ exp = payload["exp"]
263
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: #{kind} expired" if exp && now > exp + context.leeway
264
+
265
+ nbf = payload["nbf"]
266
+ return unless nbf && now < nbf - context.leeway
267
+
268
+ raise OpenID4VP::PresentationInvalid, "jwt_vc_json: #{kind} not yet valid"
269
+ end
270
+ end
271
+
272
+ Registry.default_handlers << JwtVcJson unless Registry.default_handlers.include?(JwtVcJson)
273
+ end
274
+ end