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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../formats"
5
+ require_relative "base"
6
+ require_relative "verified_presentation"
7
+ require_relative "registry"
8
+ require_relative "../errors"
9
+ require_relative "../credential"
10
+
11
+ module OpenID4VP
12
+ module Formats
13
+ # OpenID4VP Appendix B.1.3.2: the `ldp_vc` (W3C Verifiable Credential
14
+ # secured with a Data Integrity proof, using JSON-LD) Credential Format
15
+ # handler.
16
+ #
17
+ # A real deployment MUST inject the `:ldp_proof_signer` (holder-side)
18
+ # and `:ldp_proof_verifier` (verifier-side) `Context#trust` hooks: this
19
+ # gem does not implement JSON-LD normalization / Data Integrity
20
+ # cryptosuites itself. Without `:ldp_proof_signer`,
21
+ # `#build_presentation` emits a placeholder, unverifiable proof.
22
+ class LdpVc < Base
23
+ FORMAT_ID = "ldp_vc"
24
+ VP_CONTEXT = ["https://www.w3.org/2018/credentials/v1"].freeze
25
+
26
+ def self.format_id
27
+ FORMAT_ID
28
+ end
29
+
30
+ # Wraps an already-issued LDP VC Hash into a wallet-side
31
+ # `OpenID4VP::Credential`.
32
+ def self.credential_from(vc_hash, holder_key: nil)
33
+ raise OpenID4VP::PresentationInvalid, "ldp_vc: credential must be an object" unless vc_hash.is_a?(::Hash)
34
+
35
+ OpenID4VP::Credential.new(
36
+ format: FORMAT_ID,
37
+ claims: vc_hash,
38
+ raw: vc_hash,
39
+ holder_key: holder_key,
40
+ metadata: { "type" => vc_hash["type"], "issuer" => vc_hash["issuer"] }
41
+ )
42
+ end
43
+
44
+ # Builds a Verifiable Presentation Hash for `credential`. The Data
45
+ # Integrity proof comes from the `:ldp_proof_signer` trust hook
46
+ # (callable `(vp_without_proof, challenge:, domain:) -> proof Hash`)
47
+ # when configured, else a placeholder, unverifiable proof.
48
+ def build_presentation(credential, _claims_queries, context)
49
+ holder_id = credential.metadata["holder"] || holder_id_from_key(credential.holder_key)
50
+ vp = {
51
+ "@context" => VP_CONTEXT,
52
+ "type" => ["VerifiablePresentation"],
53
+ "verifiableCredential" => [credential.raw],
54
+ "holder" => holder_id
55
+ }.compact
56
+
57
+ signer = context.trust_hook(:ldp_proof_signer)
58
+ proof = signer&.call(vp, challenge: context.nonce, domain: context.audience) ||
59
+ placeholder_proof(context)
60
+ vp.merge("proof" => proof)
61
+ end
62
+
63
+ # Verifies `presentation` (a Verifiable Presentation Hash) against
64
+ # `context`. Raises ConfigurationError when holder binding is
65
+ # required but no `:ldp_proof_verifier` trust hook is configured.
66
+ def verify_presentation(presentation, context)
67
+ raise OpenID4VP::PresentationInvalid, "ldp_vc: presentation must be an object" unless presentation.is_a?(::Hash)
68
+
69
+ proof = presentation["proof"]
70
+ verified = verify_proof!(proof, presentation, context)
71
+
72
+ vc = single_verifiable_credential(presentation)
73
+
74
+ VerifiedPresentation.new(
75
+ format: FORMAT_ID,
76
+ claims: vc,
77
+ holder_bound: verified,
78
+ issuer: issuer_id(vc["issuer"]),
79
+ raw: presentation,
80
+ metadata: { "type" => vc["type"], "issuer" => vc["issuer"] },
81
+ details: { proof_type: proof.is_a?(::Hash) ? proof["type"] : nil,
82
+ cryptosuite: proof.is_a?(::Hash) ? proof["cryptosuite"] : nil }
83
+ )
84
+ end
85
+
86
+ # OpenID4VP Appendix B.1.3.2.3: `proof_type_values` restricts the
87
+ # Data Integrity proof's `type`; `cryptosuite_values` restricts its
88
+ # `cryptosuite` (when present).
89
+ def check_vp_formats!(verified, vp_formats)
90
+ return if vp_formats.nil?
91
+
92
+ params = vp_formats.params_for(FORMAT_ID)
93
+ return if params.nil?
94
+
95
+ alg_allowed!(params["proof_type_values"], verified.details[:proof_type], "proof_type_values")
96
+ alg_allowed!(params["cryptosuite_values"], verified.details[:cryptosuite], "cryptosuite_values")
97
+ end
98
+
99
+ private
100
+
101
+ def holder_id_from_key(holder_key)
102
+ return nil if holder_key.nil?
103
+
104
+ "did:jwk:#{holder_key.thumbprint_b64}"
105
+ end
106
+
107
+ def placeholder_proof(context)
108
+ {
109
+ "type" => "DataIntegrityProof",
110
+ "proofPurpose" => "authentication",
111
+ "challenge" => context.nonce,
112
+ "domain" => context.audience,
113
+ "created" => Time.now.utc.iso8601,
114
+ "proofValue" => "unverified"
115
+ }
116
+ end
117
+
118
+ # Raises on a malformed/mismatched proof; returns whether the proof
119
+ # was cryptographically verified (bang convention, matching
120
+ # OpenID4VP::TransactionData::SdJwtHashes.verify!).
121
+ # rubocop:disable Naming/PredicateMethod -- bang convention (raise on
122
+ # failure, return a boolean), matching TransactionData::SdJwtHashes.verify!.
123
+ def verify_proof!(proof, presentation, context)
124
+ raise OpenID4VP::PresentationInvalid, "ldp_vc: presentation has no proof" unless proof.is_a?(::Hash)
125
+ raise OpenID4VP::HolderBindingInvalid, "ldp_vc: challenge mismatch" unless proof["challenge"] == context.nonce
126
+ raise OpenID4VP::HolderBindingInvalid, "ldp_vc: domain mismatch" unless proof["domain"] == context.audience
127
+
128
+ # Not cryptographically verified: the proof's presence/shape is
129
+ # confirmed above, but without :ldp_proof_verifier no Data
130
+ # Integrity signature has actually been checked.
131
+ return false unless context.require_holder_binding
132
+
133
+ verifier = context.trust_hook(:ldp_proof_verifier)
134
+ raise OpenID4VP::ConfigurationError, "ldp_proof_verifier trust hook required" if verifier.nil?
135
+
136
+ unless verifier.call(presentation, context)
137
+ raise OpenID4VP::HolderBindingInvalid, "ldp_vc: proof verification failed"
138
+ end
139
+
140
+ true
141
+ end
142
+ # rubocop:enable Naming/PredicateMethod
143
+
144
+ def single_verifiable_credential(presentation)
145
+ vcs = presentation["verifiableCredential"]
146
+ unless vcs.is_a?(::Array) && vcs.size == 1 && vcs.first.is_a?(::Hash)
147
+ raise OpenID4VP::PresentationInvalid, "ldp_vc: verifiableCredential must be an array of exactly one object"
148
+ end
149
+
150
+ vcs.first
151
+ end
152
+
153
+ def issuer_id(issuer)
154
+ issuer.is_a?(::Hash) ? issuer["id"] : issuer
155
+ end
156
+ end
157
+
158
+ Registry.default_handlers << LdpVc unless Registry.default_handlers.include?(LdpVc)
159
+ end
160
+ end
@@ -0,0 +1,204 @@
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 "../jose/jwk"
10
+ require_relative "../mdoc/device_response"
11
+ require_relative "../mdoc/verifier"
12
+ require_relative "../mdoc/builder"
13
+ require_relative "../mdoc/session_transcript"
14
+ require_relative "../x509/chain_validator"
15
+
16
+ module OpenID4VP
17
+ module Formats
18
+ # OpenID4VP Appendix B.2: the `mso_mdoc` (ISO/IEC 18013-5 mdoc) Credential
19
+ # Format handler.
20
+ class MsoMdoc < Base
21
+ FORMAT_ID = "mso_mdoc"
22
+
23
+ # OpenID4VP §B.2.2: a value in `issuerauth_alg_values`/
24
+ # `deviceauth_alg_values` also matches when it is the "fully specified
25
+ # algorithm" ([I-D.ietf-jose-fully-specified-algorithms]) equivalent of
26
+ # the actual COSE `alg`, for the curve of the signing key: -7 (ES256)
27
+ # <=> -9 on a P-256 key, -35 (ES384) <=> -51 on a P-384 key, -36
28
+ # (ES512) <=> -52 on a P-521 key.
29
+ FULLY_SPECIFIED_EQUIVALENTS = {
30
+ "P-256" => { -7 => -9, -9 => -7 },
31
+ "P-384" => { -35 => -51, -51 => -35 },
32
+ "P-521" => { -36 => -52, -52 => -36 }
33
+ }.freeze
34
+
35
+ def self.format_id
36
+ FORMAT_ID
37
+ end
38
+
39
+ # Wraps a freshly-issued IssuerSigned (e.g. from
40
+ # `Mdoc::Builder.issue`) into a wallet-side `OpenID4VP::Credential`.
41
+ def self.credential_from(issuer_signed, doc_type:, device_key:)
42
+ claims = issuer_signed.name_spaces.transform_values do |items|
43
+ items.to_h { |item| [item.element_identifier, item.element_value] }
44
+ end
45
+
46
+ OpenID4VP::Credential.new(
47
+ format: FORMAT_ID, claims: claims, raw: issuer_signed, holder_key: device_key,
48
+ x5chain: issuer_signed.x5chain, metadata: { "doctype" => doc_type }
49
+ )
50
+ end
51
+
52
+ # Builds a base64url-encoded DeviceResponse for `credential` (an
53
+ # IssuerSigned wrapped via `.credential_from`), disclosing exactly the
54
+ # namespace/element pairs addressed by `claims_queries` (empty/nil:
55
+ # discloses nothing). Requires `credential.holder_key` (the mdoc
56
+ # device's private key).
57
+ def build_presentation(credential, claims_queries, context)
58
+ raise OpenID4VP::HolderBindingInvalid, "credential has no holder key" if credential.holder_key.nil?
59
+
60
+ OpenID4VP::Mdoc::Builder.device_response(
61
+ issuer_signed: credential.raw,
62
+ doc_type: credential.metadata["doctype"],
63
+ disclose: disclose_map(claims_queries),
64
+ device_key: credential.holder_key,
65
+ session_transcript: session_transcript(context)
66
+ ).to_b64url
67
+ end
68
+
69
+ # Verifies `presentation` (a base64url-encoded DeviceResponse String,
70
+ # OpenID4VP §B.2.5) against `context`. Raises ConfigurationError when
71
+ # no `:mdoc_trust_anchors` trust hook is configured.
72
+ def verify_presentation(presentation, context)
73
+ unless presentation.is_a?(::String)
74
+ raise OpenID4VP::PresentationInvalid, "mso_mdoc: presentation must be a string"
75
+ end
76
+
77
+ document = single_document(parse_device_response(presentation))
78
+ result = verify_document(document, context)
79
+
80
+ VerifiedPresentation.new(
81
+ format: FORMAT_ID,
82
+ claims: result.claims,
83
+ holder_bound: true,
84
+ issuer: result.issuer_certs.first.subject.to_s,
85
+ raw: presentation,
86
+ x5chain: result.issuer_certs,
87
+ metadata: { "doctype" => result.doc_type },
88
+ details: { issuer_alg: result.issuer_alg, device_alg: result.device_alg, device_key: result.device_key }
89
+ )
90
+ end
91
+
92
+ # OpenID4VP §B.2.2: `issuerauth_alg_values` restricts the IssuerAuth
93
+ # COSE_Sign1's `alg`; `deviceauth_alg_values` restricts the
94
+ # DeviceSignature COSE_Sign1's `alg`. Both also accept the alg's fully
95
+ # specified equivalent for the actual signing key's curve.
96
+ def check_vp_formats!(verified, vp_formats)
97
+ return if vp_formats.nil?
98
+
99
+ params = vp_formats.params_for(FORMAT_ID)
100
+ return if params.nil?
101
+
102
+ check_alg!(params["issuerauth_alg_values"], verified.details[:issuer_alg], issuer_curve(verified),
103
+ "issuerauth_alg_values")
104
+ check_alg!(params["deviceauth_alg_values"], verified.details[:device_alg], device_curve(verified),
105
+ "deviceauth_alg_values")
106
+ end
107
+
108
+ private
109
+
110
+ def disclose_map(claims_queries)
111
+ (claims_queries || []).each_with_object({}) do |claims_query, disclose|
112
+ namespace, element = claims_query.path.mdoc_components
113
+ (disclose[namespace] ||= []) << element
114
+ end
115
+ end
116
+
117
+ # OpenID4VP §B.2.6: the SessionTranscript array binding the
118
+ # DeviceResponse to this OpenID4VP exchange -- OpenID4VPHandover for a
119
+ # redirect-based request, OpenID4VPDCAPIHandover for a Digital
120
+ # Credentials API request.
121
+ def session_transcript(context)
122
+ if context.dc_api
123
+ if context.origin.nil?
124
+ raise OpenID4VP::ConfigurationError, "mso_mdoc: origin is required for dc_api presentations"
125
+ end
126
+
127
+ OpenID4VP::Mdoc::SessionTranscript.for_dc_api(
128
+ origin: context.origin, nonce: context.nonce, encryption_jwk: context.encryption_jwk
129
+ )
130
+ else
131
+ if context.client_id.nil? || context.response_uri.nil?
132
+ raise OpenID4VP::ConfigurationError,
133
+ "mso_mdoc: client_id and response_uri are required for redirect-based presentations"
134
+ end
135
+
136
+ OpenID4VP::Mdoc::SessionTranscript.for_request(
137
+ client_id: context.client_id.to_s, nonce: context.nonce, response_uri: context.response_uri,
138
+ encryption_jwk: context.encryption_jwk
139
+ )
140
+ end
141
+ end
142
+
143
+ def parse_device_response(presentation)
144
+ OpenID4VP::Mdoc::DeviceResponse.parse_b64url(presentation)
145
+ rescue OpenID4VP::InvalidRequest, OpenID4VP::InvalidResponse => e
146
+ raise OpenID4VP::PresentationInvalid, "mso_mdoc: #{e.message}"
147
+ end
148
+
149
+ def single_document(device_response)
150
+ documents = device_response.documents
151
+ if documents.size != 1
152
+ raise OpenID4VP::PresentationInvalid,
153
+ "mso_mdoc: DeviceResponse must contain exactly one document, got #{documents.size}"
154
+ end
155
+
156
+ documents.first
157
+ end
158
+
159
+ def verify_document(document, context)
160
+ trust_anchors = context.trust_hook(:mdoc_trust_anchors)
161
+ if trust_anchors.nil? || Array(trust_anchors).empty?
162
+ raise OpenID4VP::ConfigurationError, "mdoc_trust_anchors trust hook required"
163
+ end
164
+
165
+ validator = lambda do |chain|
166
+ OpenID4VP::X509::ChainValidator.new(trust_anchors: trust_anchors, time: context.now).validate!(chain)
167
+ end
168
+
169
+ # Mdoc::Verifier.verify itself raises correctly-typed
170
+ # OpenID4VP::PresentationInvalid / OpenID4VP::HolderBindingInvalid
171
+ # for every Credential-side/Holder Binding failure -- nothing to map
172
+ # here.
173
+ OpenID4VP::Mdoc::Verifier.verify(
174
+ document, session_transcript: session_transcript(context), issuer_cert_validator: validator,
175
+ now: context.now
176
+ )
177
+ end
178
+
179
+ # Substitutes `alg`'s fully specified equivalent (for `curve`) when
180
+ # `values` does not already include `alg` directly, then delegates to
181
+ # `Base#alg_allowed!` for the actual restriction check/error.
182
+ def check_alg!(values, alg, curve, param_name)
183
+ return if values.nil? || alg.nil?
184
+
185
+ candidate = values.include?(alg) ? alg : (FULLY_SPECIFIED_EQUIVALENTS.dig(curve, alg) || alg)
186
+ alg_allowed!(values, candidate, param_name)
187
+ end
188
+
189
+ def issuer_curve(verified)
190
+ leaf = verified.x5chain.first
191
+ return nil if leaf.nil?
192
+
193
+ OpenID4VP::JOSE::JWK.from_openssl(leaf.public_key).crv
194
+ end
195
+
196
+ def device_curve(verified)
197
+ verified.details[:device_key]&.crv
198
+ end
199
+ end
200
+
201
+ OpenID4VP::Formats::Registry.default_handlers << OpenID4VP::Formats::MsoMdoc unless
202
+ OpenID4VP::Formats::Registry.default_handlers.include?(OpenID4VP::Formats::MsoMdoc)
203
+ end
204
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formats"
4
+ require_relative "../errors"
5
+
6
+ module OpenID4VP
7
+ module Formats
8
+ # A collection of `Base` handler instances, keyed by Credential Format
9
+ # Identifier (`#format_id`).
10
+ class Registry
11
+ # Handler CLASSES (not instances) registered by `.default`. Each
12
+ # format handler's own file appends itself here at load time; a
13
+ # caller assembling a custom Registry may also append/remove entries
14
+ # before calling `.default`.
15
+ def self.default_handlers
16
+ @default_handlers ||= []
17
+ end
18
+
19
+ # A new Registry with one instance of each of `default_handlers`
20
+ # registered.
21
+ def self.default
22
+ registry = new
23
+ default_handlers.each { |handler_class| registry.register(handler_class.new) }
24
+ registry
25
+ end
26
+
27
+ def initialize
28
+ @handlers = {}
29
+ end
30
+
31
+ def register(handler)
32
+ @handlers[handler.format_id] = handler
33
+ self
34
+ end
35
+
36
+ # The handler for `format` (a Credential Format Identifier String).
37
+ # Raises VpFormatsNotSupported when none is registered.
38
+ def for(format)
39
+ @handlers.fetch(format) do
40
+ raise OpenID4VP::VpFormatsNotSupported, "unsupported credential format: #{format}"
41
+ end
42
+ end
43
+
44
+ def supports?(format)
45
+ @handlers.key?(format)
46
+ end
47
+
48
+ def formats
49
+ @handlers.keys
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,132 @@
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 "../sd_jwt/presentation"
12
+ require_relative "../sd_jwt/parser"
13
+ require_relative "../sd_jwt/reconstruct"
14
+
15
+ module OpenID4VP
16
+ module Formats
17
+ # OpenID4VP Appendix B.3: the `dc+sd-jwt` (IETF SD-JWT VC) Credential
18
+ # Format handler.
19
+ class SdJwtVc < Base
20
+ FORMAT_ID = "dc+sd-jwt"
21
+
22
+ def self.format_id
23
+ FORMAT_ID
24
+ end
25
+
26
+ # Parses+reconstructs the FULL (unverified) claims of an issued SD-JWT
27
+ # (Issuer + all disclosures, no signature/holder-binding check) into a
28
+ # wallet-side `OpenID4VP::Credential`, e.g. right after issuance.
29
+ def self.credential_from(sd_jwt_string, holder_key: nil)
30
+ parsed = OpenID4VP::SdJwt::Parser.parse(sd_jwt_string)
31
+ peeked = OpenID4VP::JOSE::JWS.peek(parsed.issuer_jwt)
32
+ payload = peeked.payload
33
+ raise OpenID4VP::PresentationInvalid, "sd-jwt: payload is not a JSON object" unless payload.is_a?(::Hash)
34
+
35
+ sd_alg = payload["_sd_alg"] || "sha-256"
36
+ claims, = OpenID4VP::SdJwt::Reconstruct.claims(payload, parsed.disclosures, sd_alg)
37
+
38
+ OpenID4VP::Credential.new(
39
+ format: FORMAT_ID,
40
+ claims: claims,
41
+ raw: sd_jwt_string,
42
+ holder_key: holder_key,
43
+ x5chain: x5chain_from_header(peeked.header),
44
+ metadata: { "vct" => payload["vct"], "issuer" => payload["iss"] }
45
+ )
46
+ end
47
+
48
+ def self.x5chain_from_header(header)
49
+ x5c = header["x5c"]
50
+ return [] unless x5c.is_a?(::Array)
51
+
52
+ OpenID4VP::X509.certs_from_x5c(x5c)
53
+ end
54
+ private_class_method :x5chain_from_header
55
+
56
+ # Verifies `presentation` (a `"<issuer-jwt>~<disclosures>~[<kb-jwt>]"`
57
+ # String) against `context`. Raises ConfigurationError when no
58
+ # `:sd_jwt_issuer_keys` trust hook is configured.
59
+ def verify_presentation(presentation, context)
60
+ unless presentation.is_a?(::String)
61
+ raise OpenID4VP::PresentationInvalid, "dc+sd-jwt: presentation must be a string"
62
+ end
63
+
64
+ resolver = context.trust_hook(:sd_jwt_issuer_keys)
65
+ raise OpenID4VP::ConfigurationError, "sd_jwt_issuer_keys trust hook required" if resolver.nil?
66
+
67
+ result = OpenID4VP::SdJwt::Presentation.verify(
68
+ presentation,
69
+ issuer_key_resolver: resolver,
70
+ expected_nonce: context.nonce,
71
+ expected_aud: context.audience,
72
+ require_kb: context.require_holder_binding,
73
+ now: context.now,
74
+ leeway: context.leeway,
75
+ transaction_data: context.transaction_data
76
+ )
77
+
78
+ VerifiedPresentation.new(
79
+ format: FORMAT_ID,
80
+ claims: result.claims,
81
+ holder_bound: !result.kb_payload.nil?,
82
+ issuer: result.issuer,
83
+ raw: presentation,
84
+ x5chain: result.x5chain || [],
85
+ metadata: { "vct" => result.vct },
86
+ details: { alg: result.alg, kb_alg: result.kb_alg, kb_payload: result.kb_payload }
87
+ )
88
+ end
89
+
90
+ # OpenID4VP Appendix B.3.4: `sd-jwt_alg_values` restricts the
91
+ # Issuer-signed JWT's `alg`; `kb-jwt_alg_values` restricts the Key
92
+ # Binding JWT's `alg` (only enforced when the Presentation is holder
93
+ # bound).
94
+ def check_vp_formats!(verified, vp_formats)
95
+ return if vp_formats.nil?
96
+
97
+ params = vp_formats.params_for(FORMAT_ID)
98
+ return if params.nil?
99
+
100
+ alg_allowed!(params["sd-jwt_alg_values"], verified.details[:alg], "sd-jwt_alg_values")
101
+ return unless verified.holder_bound
102
+
103
+ alg_allowed!(params["kb-jwt_alg_values"], verified.details[:kb_alg], "kb-jwt_alg_values")
104
+ end
105
+
106
+ # Builds a Presentation for `credential` disclosing the claims
107
+ # addressed by `claims_queries` (may be empty/nil: only mandatory,
108
+ # always-disclosed claims are revealed). Binds with a Key Binding JWT
109
+ # whenever `credential.holder_key` is available (regardless of
110
+ # `context.require_holder_binding` -- binding when possible is always
111
+ # at least as strong as not binding); otherwise falls back to a plain
112
+ # (unbound) Presentation when `context.require_holder_binding` is
113
+ # false, or raises `HolderBindingInvalid` when it is true.
114
+ def build_presentation(credential, claims_queries, context)
115
+ paths = (claims_queries || []).map(&:path)
116
+
117
+ if credential.holder_key
118
+ OpenID4VP::SdJwt::Presentation.present(
119
+ credential.raw, paths: paths, holder_key: credential.holder_key, aud: context.audience,
120
+ nonce: context.nonce, transaction_data: context.transaction_data
121
+ )
122
+ elsif !context.require_holder_binding
123
+ OpenID4VP::SdJwt::Presentation.select(credential.raw, paths: paths)
124
+ else
125
+ raise OpenID4VP::HolderBindingInvalid, "credential has no holder key"
126
+ end
127
+ end
128
+ end
129
+
130
+ Registry.default_handlers << SdJwtVc unless Registry.default_handlers.include?(SdJwtVc)
131
+ end
132
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formats"
4
+ require_relative "../credential"
5
+
6
+ module OpenID4VP
7
+ module Formats
8
+ # The result of `Base#verify_presentation`: a single Presentation's
9
+ # verified content, format-agnostic enough for `DCQL::Evaluator` (via
10
+ # `#to_credential`) and for the Verifier's response handling. Immutable.
11
+ class VerifiedPresentation
12
+ attr_reader :format, :claims, :holder_bound, :issuer, :raw, :x5chain, :metadata, :details
13
+
14
+ # format: the Credential Format Identifier, e.g. "dc+sd-jwt".
15
+ # claims: JSON-like Hash of the Credential's (disclosed) claims; for
16
+ # mso_mdoc, {namespace => {element => value}}.
17
+ # holder_bound: whether this Presentation carried a verified proof of
18
+ # Holder Binding (a Key Binding JWT, a Data Integrity proof, ...).
19
+ # raw: the exact Presentation that was verified.
20
+ # x5chain: Array of OpenSSL::X509::Certificate, leaf first.
21
+ # metadata: format-specific descriptors, e.g. {"vct"=>...}.
22
+ # details: format-specific extras later tasks read, e.g. {alg:,
23
+ # kb_alg:, kb_payload:} for dc+sd-jwt.
24
+ def initialize(format:, claims:, holder_bound:, raw:, issuer: nil, x5chain: [], metadata: {}, details: {})
25
+ @format = format
26
+ @claims = claims
27
+ @holder_bound = holder_bound
28
+ @issuer = issuer
29
+ @raw = raw
30
+ @x5chain = x5chain.dup.freeze
31
+ @metadata = metadata.dup.freeze
32
+ @details = details.dup.freeze
33
+ freeze
34
+ end
35
+
36
+ def holder_bound?
37
+ holder_bound
38
+ end
39
+
40
+ def to_credential
41
+ OpenID4VP::Credential.new(format: format, claims: claims, raw: raw, x5chain: x5chain, metadata: metadata)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # The pluggable layer between the OpenID4VP protocol (Verifier/Wallet) and
5
+ # each Credential Format (`dc+sd-jwt`, `jwt_vc_json`, `ldp_vc`,
6
+ # `mso_mdoc`, ...): a `Registry` of `Base` subclass instances, one per
7
+ # supported Credential Format Identifier, each translating a raw
8
+ # Presentation into a `VerifiedPresentation` / `OpenID4VP::Credential` (or
9
+ # the reverse, when building one).
10
+ module Formats
11
+ end
12
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../jose"
4
+ require_relative "../errors"
5
+
6
+ module OpenID4VP
7
+ module JOSE
8
+ # JWS algorithm helpers: default alg selection per key and alg/key
9
+ # compatibility checks.
10
+ module Algorithms
11
+ SIGNING = %w[ES256 ES384 ES512 RS256 PS256 EdDSA].freeze
12
+
13
+ KEY_MANAGEMENT = %w[ECDH-ES ECDH-ES+A128KW ECDH-ES+A256KW].freeze
14
+
15
+ CONTENT_ENCRYPTION = %w[A128GCM A256GCM A128CBC-HS256].freeze
16
+
17
+ EC_ALG_FOR_CRV = { "P-256" => "ES256", "P-384" => "ES384", "P-521" => "ES512" }.freeze
18
+
19
+ module_function
20
+
21
+ # The default JWS `alg` to use for signing with `jwk`.
22
+ def for_key(jwk)
23
+ case jwk.kty
24
+ when "EC" then EC_ALG_FOR_CRV.fetch(jwk.crv) { raise OpenID4VP::InvalidRequest, "unsupported EC curve #{jwk.crv}" }
25
+ when "OKP" then "EdDSA"
26
+ when "RSA" then "RS256"
27
+ else raise OpenID4VP::InvalidRequest, "jose: unsupported kty #{jwk.kty.inspect} for signing"
28
+ end
29
+ end
30
+
31
+ # Whether `alg` is a JWS algorithm that can be used with `jwk`.
32
+ def key_matches?(alg, jwk)
33
+ case alg
34
+ when "ES256", "ES384", "ES512"
35
+ jwk.kty == "EC" && EC_ALG_FOR_CRV[jwk.crv] == alg
36
+ when "RS256", "PS256"
37
+ jwk.kty == "RSA"
38
+ when "EdDSA"
39
+ jwk.kty == "OKP" && jwk.crv == "Ed25519"
40
+ else
41
+ false
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end