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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b0c830e2e8f4dc1ae753f93fb307de98e94ba8777bb769f309e7619e2173fc7b
4
+ data.tar.gz: e2cf603ad7f278c1a0acff71e2717ff4d7322cfdf2fc9618ca212830a75afd1f
5
+ SHA512:
6
+ metadata.gz: 1372146a5ef0c5b214c12eafdbb5709b5e7f8644e15b7050685445894671fc80ad408df64c07d59f7e6f9566ad6d36c351d219ad7c7b0c19a02c5d93fd6bdbf2
7
+ data.tar.gz: 2dea241906280ef14efcffe9b09f0f639acdd8f9adbf3d55bb57854b84b97d978f71ff96c99e7157ef8e9cf7ceb5149f8aa6a720a8bc32a3e52abc437cae9e28
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - Unreleased
4
+
5
+ Initial release.
6
+
7
+ - Verifier: request building and signing (JAR/RFC 9101), the Request URI
8
+ endpoint (GET/POST, `wallet_metadata`/`wallet_nonce` tailoring) and the
9
+ Response endpoint (`direct_post`/`direct_post.jwt`/`dc_api`/`dc_api.jwt`),
10
+ an in-memory session store, VP Token validation and `fetch_response`.
11
+ - Wallet: request parsing from a URL or a Digital Credentials API payload,
12
+ Client Identifier Prefix verification, DCQL evaluation against holder
13
+ Credentials, presentation building, and response submission.
14
+ - Client Identifier Prefixes: `redirect_uri`, `x509_san_dns`, `x509_hash`,
15
+ `verifier_attestation`, `decentralized_identifier`, pre-registered, and
16
+ `origin` (DC API only); `openid_federation` is parsed but trust-chain
17
+ resolution is a pluggable hook.
18
+ - DCQL: full data model, claims path pointer processing (JSON and mdoc),
19
+ and the §6.4 selection algorithm (`claims`, `claim_sets`,
20
+ `credential_sets`, `values` matching, `trusted_authorities` `aki`
21
+ matching, with `etsi_tl`/`openid_federation` as pluggable hooks).
22
+ - Response Modes: `fragment`, `query`, `direct_post`, `direct_post.jwt`,
23
+ `dc_api`, `dc_api.jwt`, with encrypted responses (JWE `ECDH-ES`,
24
+ `ECDH-ES+A128KW`, `ECDH-ES+A256KW`; `A128GCM`, `A256GCM`,
25
+ `A128CBC-HS256`), ephemeral or static encryption keys.
26
+ - Transaction Data, Verifier Info, and Verifier Attestation JWT issuance
27
+ and validation.
28
+ - Credential formats: `dc+sd-jwt` (full presentation verification and a
29
+ test issuer), `jwt_vc_json`, `mso_mdoc` (DeviceResponse/IssuerAuth/MSO
30
+ digest/DeviceSignature verification; DeviceMac not supported), `ldp_vc`
31
+ (structural verification, with Data Integrity proof verification as a
32
+ pluggable hook); a format registry for unknown formats.
33
+ - A plain Rack example Verifier app under `examples/`.
34
+ - Delegate JOSE/COSE to ecosystem gems instead of hand-rolled implementations:
35
+ JWK to `jose`, compact JWS to `jwt` (+ `jwt-eddsa` for `EdDSA`), JWE to
36
+ `jose`, and COSE_Sign1/COSE keys to `cose`; X.509 stays on stdlib
37
+ `openssl`. Requires `cose ~> 1.4`, which has not been released yet -- see
38
+ README "Installation"/"Development" for the interim `github:` source
39
+ pointing at the `sign1-signing` branch of `cedarcode/cose-ruby`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Braulio Martinez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,278 @@
1
+ # OpenID4VP
2
+
3
+ [OpenID for Verifiable Presentations 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html)
4
+ for Ruby: a framework-agnostic `Verifier` and `Wallet` implementation
5
+ covering Authorization Requests (JAR/RFC 9101, all Client Identifier
6
+ Prefixes), DCQL credential queries, every Response Mode (including
7
+ encrypted responses and the W3C Digital Credentials API), and the
8
+ `dc+sd-jwt`, `jwt_vc_json`, `mso_mdoc` and `ldp_vc` Credential Formats. A
9
+ plain Rack example Verifier app is included under `examples/`.
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem "openid4vp"
17
+ ```
18
+
19
+ Then `bundle install`. Ruby >= 3.2 is required. Runtime dependencies:
20
+ `cbor`, `cose` (~> 1.4), `jose`, `jwt` and `jwt-eddsa` -- see "Cryptography"
21
+ below for what each one is used for; X.509 parsing/chain validation stays on
22
+ stdlib `openssl`. `openssl-signature_algorithm` is not a direct dependency of
23
+ this gem; it comes in transitively via `cose`.
24
+
25
+ **`cose` 1.4 has not been released yet.** This gem needs
26
+ `COSE::Sign1#sign`/`#serialize` and detached-payload `#verify`, which
27
+ currently only exist on the `sign1-signing` branch of
28
+ [`cedarcode/cose-ruby`](https://github.com/cedarcode/cose-ruby/tree/sign1-signing)
29
+ -- see "Development" below for how to build against it in the meantime. Publishing `openid4vp` for real requires releasing `cose` 1.4.0
30
+ first.
31
+
32
+ ## Cryptography
33
+
34
+ - **JWK** (RFC 7517 key representation; EC, RSA and OKP incl.
35
+ X25519/X448) -- the `jose` gem (`JOSE::JWK`).
36
+ - **Compact JWS** (Request Object / VP Token signing:
37
+ `ES256`/`ES384`/`ES512`/`RS256`/`PS256`/`EdDSA`) -- the `jwt` gem's JWA
38
+ registry, with `jwt-eddsa` adding `EdDSA` support. `jwt-eddsa` brings in
39
+ the `ed25519` gem, a native (C extension) dependency; EdDSA request
40
+ signing is optional -- skip generating/using an OKP `Ed25519` signing key
41
+ if you'd rather not carry that native dependency.
42
+ - **JWS JSON Serialization** (DC API multisigned requests, Appendix A) --
43
+ built on the same `jwt`-backed sign/verify primitives as compact JWS
44
+ above, not `JOSE::JWS`.
45
+ - **JWE** (encrypted Authorization Responses; the `ECDH-ES` family) -- the
46
+ `jose` gem (`JOSE::JWE`). `JOSE.crypto_fallback = true` is set at load
47
+ time, which is what makes X25519/X448 key agreement work at all without
48
+ the optional `rbnacl`/`x25519` gems: without one of those installed, the
49
+ `jose` gem's ECDH-ES for OKP (X25519/X448) recipients runs through its
50
+ pure-Ruby fallback implementation, which is **not constant-time** (timing
51
+ side-channel risk). The default and most common case -- EC (P-256/384/521)
52
+ recipients -- is unaffected; this only matters if you configure a static
53
+ X25519/X448 recipient key. If you do, install `rbnacl` (preferred) or
54
+ `x25519` so `jose` picks the native implementation instead.
55
+ - **COSE_Sign1** (mdoc IssuerAuth/DeviceSignature) and COSE keys -- the
56
+ `cose` gem. Known `cose-ruby` limitation: verification re-encodes the
57
+ protected header from the parsed map rather than reusing the original
58
+ bytes, so a foreign mdoc with non-canonical protected-header CBOR may fail
59
+ to verify even though it is spec-valid.
60
+ - **X.509** certificate parsing and chain validation -- stdlib `openssl`
61
+ (unchanged; not delegated to a gem).
62
+
63
+ ## Verifier quick start
64
+
65
+ ```ruby
66
+ require "openid4vp"
67
+
68
+ verifier = OpenID4VP::Verifier.new(
69
+ client_id: "x509_san_dns:verifier.example",
70
+ response_uri: "https://verifier.example/oid4vp/response",
71
+ request_uri_base: "https://verifier.example/oid4vp/request", # request_uri = "#{request_uri_base}/#{request_id}"
72
+ redirect_uri_base: "https://verifier.example/oid4vp/callback", # enables the response_code redirect (OpenID4VP §8.2)
73
+ signing: { key: verifier_jwk, x5c: [verifier_cert_pem] }, # nil for the redirect_uri prefix
74
+ encryption: { ephemeral: true }, # for *.jwt response modes; any Hash without :keys is ephemeral --
75
+ # `ephemeral: true` is optional sugar, not itself inspected -- or
76
+ # { keys: [...] } for a static key
77
+ trust: {
78
+ sd_jwt_issuer_keys: ->(iss, _header) { issuer_jwk_for(iss) },
79
+ mdoc_trust_anchors: [mdl_ca_cert]
80
+ }
81
+ )
82
+
83
+ # OpenID4VP §5, §13.3 steps 2-4: build and store the Authorization Request.
84
+ created = verifier.create_request(
85
+ dcql_query: { "credentials" => [
86
+ { "id" => "pid", "format" => "dc+sd-jwt", "meta" => { "vct_values" => ["https://credentials.example.com/pid"] },
87
+ "claims" => [{ "path" => ["given_name"] }] }
88
+ ] },
89
+ response_mode: "direct_post.jwt",
90
+ request_uri_method: "post"
91
+ )
92
+ created.session_id # stash this -- it is what you poll fetch_response with
93
+ created.url # hand this to the Wallet (render as a QR code, or use as a same-device deep link)
94
+
95
+ # Wire your framework's routes to the endpoints (see examples/verifier_app.rb
96
+ # for a complete plain-Rack controller):
97
+ verifier.handle_request_uri(request_id, method: "GET", params: {}, headers: {}) # OpenID4VP §5.10
98
+ verifier.handle_response(params, headers: {}) # OpenID4VP §8.2
99
+
100
+ # Once the Wallet has responded (§8.6/§14.1); response_code is REQUIRED
101
+ # whenever redirect_uri_base is configured, and returns nil while pending:
102
+ result = verifier.fetch_response(created.session_id, response_code: response_code)
103
+ result&.claims # => {"pid" => [{"given_name" => "Erika"}]}
104
+ ```
105
+
106
+ `Verifier.new` accepts (see `lib/openid4vp/verifier/config.rb` for the full,
107
+ authoritative list): `client_id:`, `response_uri:`, `redirect_uri:`,
108
+ `request_uri_base:`, `redirect_uri_base:`, `authorization_endpoint:`,
109
+ `signing:`, `attestation_jwt:`, `encryption:`, `vp_formats_supported:`,
110
+ `client_metadata:`, `session_store:` (defaults to the in-process
111
+ `MemorySessionStore`; supply your own for a multi-process deployment),
112
+ `trust:`, `formats:`, `transaction_data_types:`, `scope_resolver:`,
113
+ `session_ttl:`, `clock:`, `verify_on_receipt:`.
114
+
115
+ ## Wallet quick start
116
+
117
+ ```ruby
118
+ wallet = OpenID4VP::Wallet.new(
119
+ metadata: OpenID4VP::Metadata::WalletMetadata.new(
120
+ "response_types_supported" => ["vp_token"],
121
+ "vp_formats_supported" => { "dc+sd-jwt" => { "sd-jwt_alg_values" => ["ES256"], "kb-jwt_alg_values" => ["ES256"] } },
122
+ "client_id_prefixes_supported" => %w[x509_san_dns redirect_uri pre-registered]
123
+ ),
124
+ verification: {
125
+ x509_trust_anchors: [verifier_ca_cert],
126
+ pre_registered_clients: { "client-1" => { metadata: {...}, jwks: {...}, redirect_uris: [...] } }
127
+ }
128
+ )
129
+
130
+ # The one-call convenience (processes, evaluates, builds and submits):
131
+ submission = wallet.respond(url_or_params, credentials) # credentials: Array of OpenID4VP::Credential
132
+
133
+ # Or step by step, e.g. to let the user pick among multiple matches:
134
+ processed = wallet.process_request(url_or_params) # or wallet.process_dc_api_request(protocol, data, origin:)
135
+ selection = wallet.evaluate(processed, credentials) # DCQL::Selection
136
+ built = wallet.build_response(processed, selection) # raises AccessDenied when not selection.satisfiable?
137
+ submission = wallet.submit(processed, built) # Submission(kind: :posted/:redirect/:dc_api, redirect_uri:, data:, status:)
138
+ ```
139
+
140
+ `Wallet.new` accepts `metadata:`, `http:` (defaults to a `Net::HTTP`-based
141
+ `HttpClient`; inject your own adapter -- anything responding to
142
+ `get(url, headers:)` / `post(url, body:, headers:)` -> `Response(status:,
143
+ headers:, body:)` -- for testing or a different HTTP stack), `verification:`
144
+ (a Hash normalized into `RequestVerification::Config`, or an instance
145
+ directly), `formats:`, `transaction_data_types:`, `scope_resolver:`,
146
+ `clock:`, `holder_keys:`.
147
+
148
+ ## Supported features
149
+
150
+ ### Client Identifier Prefixes (OpenID4VP §5.9)
151
+
152
+ | Prefix | Notes |
153
+ |---|---|
154
+ | `redirect_uri` | Requests MUST NOT be signed; the Verifier metadata comes entirely from `client_metadata`. |
155
+ | `x509_san_dns` | Request signed and carries `x5c`; the leaf certificate's `subjectAltName` DNS entry must equal the client_id. |
156
+ | `x509_hash` | Like `x509_san_dns`, but the client_id is the leaf certificate's base64url hash. |
157
+ | `verifier_attestation` | Signed with a key attested by a trusted issuer's Verifier Attestation JWT (`typ: verifier-attestation+jwt`). |
158
+ | `decentralized_identifier` | Signed with a key resolved from a DID document via an injected `did_resolver:` callback. |
159
+ | pre-registered (no prefix) | Metadata/keys looked up from `pre_registered_clients:`; MUST NOT carry `client_metadata`. |
160
+ | `origin` | DC API only, wallet-derived from the calling origin; rejected if present in a request. |
161
+ | `openid_federation` | Parsed; trust-chain resolution is a pluggable `federation_resolver:` hook, not implemented. |
162
+
163
+ ### Response Modes and request delivery
164
+
165
+ | Response Mode | Notes |
166
+ |---|---|
167
+ | `fragment` / `query` | Unencrypted redirect; the Wallet builds a URL for the caller's HTTP layer to redirect to. |
168
+ | `direct_post` / `direct_post.jwt` | Wallet POSTs the response to `response_uri`; `.jwt` encrypts it (JWE). |
169
+ | `dc_api` / `dc_api.jwt` | W3C Digital Credentials API; `.jwt` encrypts. Requests can be unsigned, signed, or multisigned (Appendix A). |
170
+
171
+ Requests can be delivered by value (`request`), or by reference
172
+ (`request_uri`) with `request_uri_method` `get` or `post` (the `post`
173
+ variant lets the Wallet send `wallet_metadata`/`wallet_nonce` so the
174
+ Verifier can tailor encryption/format/signing-alg to it, per §5.10).
175
+
176
+ ### Credential formats (Appendix B)
177
+
178
+ | Format | What is verified |
179
+ |---|---|
180
+ | `dc+sd-jwt` | Issuer JWS, SD-JWT disclosure digests, Key Binding JWT (`sd_hash`/`nonce`/`aud`/`iat`), `transaction_data_hashes`, `vct_values` matching. Includes a test issuer. |
181
+ | `jwt_vc_json` | VP JWT (`nonce`/`aud`), exactly one enclosed VC JWT (bound to its subject: VP `iss` must match the VC's `sub`/`credentialSubject.id`, or a `cnf.jwk` thumbprint), `type_values` matching. |
182
+ | `mso_mdoc` | `OpenID4VPHandover`/`OpenID4VPDCAPIHandover` + `SessionTranscript`, DeviceResponse parsing, IssuerAuth (COSE_Sign1) and MSO digest verification, DeviceSignature over DeviceAuthentication, `doctype_value` matching. `intent_to_retain` is parsed and preserved in the DCQL model; not otherwise consumed. **DeviceMac is not supported.** |
183
+ | `ldp_vc` | Structural parsing, `challenge`/`domain` checks, `type_values` matching. **Data Integrity proof verification is a pluggable hook** (`trust[:ldp_proof_verifier]`) -- no JSON-LD canonicalization is implemented in this gem. |
184
+
185
+ Unknown formats are pluggable via `OpenID4VP::Formats::Registry`.
186
+
187
+ ### JOSE algorithms
188
+
189
+ - Signing (JWS / Request Objects): `ES256`, `ES384`, `ES512`, `RS256`,
190
+ `PS256`, `EdDSA`.
191
+ - Key management (JWE, encrypted responses): `ECDH-ES`, `ECDH-ES+A128KW`,
192
+ `ECDH-ES+A256KW`.
193
+ - Content encryption (JWE): `A128GCM` (default), `A256GCM`,
194
+ `A128CBC-HS256`.
195
+
196
+ ### DCQL (§6, §7)
197
+
198
+ Full data model and validation; claims path pointer processing for both
199
+ JSON and mdoc namespace semantics; the §6.4 selection algorithm --
200
+ `claims`, `claim_sets`, `credential_sets` (including optional sets),
201
+ `values` matching, and `trusted_authorities` (built-in `aki` matching, with
202
+ `etsi_tl`/`openid_federation` as pluggable `trust[:authority_hooks]`
203
+ hooks) -- plus `require_cryptographic_holder_binding: false` support.
204
+
205
+ ### Transaction Data and Verifier Info
206
+
207
+ Transaction Data parsing (§5.1, §8.4) with a pluggable type registry and the
208
+ SD-JWT `transaction_data_hashes` profile (§B.3.3); Verifier Info parsing
209
+ (§5.11, §12); Verifier Attestation JWT issuance and validation.
210
+
211
+ ### DC API (Appendix A)
212
+
213
+ Request shapes for `openid4vp-v1-unsigned`, `-signed` and `-multisigned`
214
+ (JWS JSON serialization with a per-signature `client_id`), `expected_origins`
215
+ checking, `origin:` as the presentation audience, and `dc_api`/`dc_api.jwt`
216
+ responses.
217
+
218
+ ## Trust hooks
219
+
220
+ Presentation and authority verification that depends on your PKI/issuer
221
+ registry is injected via `trust:` (on both `Verifier.new` and, per format
222
+ handler, threaded through `OpenID4VP::Formats::Context`):
223
+
224
+ ```ruby
225
+ trust: {
226
+ sd_jwt_issuer_keys: ->(iss, header) { ... }, # (iss, jws_header) -> JWK | [JWK]
227
+ jwt_vc_issuer_keys: ->(iss, header) { ... },
228
+ jwt_vc_holder_keys: ->(iss, header) { ... },
229
+ mdoc_trust_anchors: [ca_cert, ...], # Array of OpenSSL::X509::Certificate
230
+ ldp_proof_verifier: ->(vp_hash, context) { ... }, # (vp_hash, Formats::Context) -> bool
231
+ authority_hooks: { # OpenID4VP §6.1.1 trusted_authorities the built-in
232
+ "etsi_tl" => ->(ta_query, credential) { ... } # "aki" matcher can't check
233
+ }
234
+ }
235
+ ```
236
+
237
+ ## Limitations
238
+
239
+ Out of scope for this gem (design doc §1.3), plus two implemented-but-partial
240
+ credential-format cases:
241
+
242
+ - OpenID Federation trust-chain resolution (the prefix is parsed; supply
243
+ your own `federation_resolver:`/`trust[:authority_hooks]["openid_federation"]`).
244
+ - DID resolution (a `did_resolver:` callback is accepted, not implemented).
245
+ - ETSI Trusted Lists resolution (`trust[:authority_hooks]["etsi_tl"]` is a hook).
246
+ - JSON-LD Data Integrity proof cryptography for `ldp_vc` -- no JSON-LD
247
+ canonicalization is implemented; verification is a pluggable
248
+ `trust[:ldp_proof_verifier]` hook.
249
+ - `mso_mdoc` DeviceMac (only DeviceSignature is supported).
250
+ - SIOPv2 (`id_token`) and `response_type=code` / the OAuth token endpoint.
251
+ - SD-JWT VC type metadata resolution, and revocation/status checks.
252
+ - QR code rendering and any UI -- `create_request`'s `url` is a plain
253
+ String for your own rendering.
254
+
255
+ ## Development
256
+
257
+ ```
258
+ bundle install
259
+ bundle exec rake # runs the full RSpec suite and RuboCop
260
+ ```
261
+
262
+ The `Gemfile` points the `cose` gem at the `sign1-signing` branch of
263
+ `cedarcode/cose-ruby` via a `github:` source, since the `COSE::Sign1`
264
+ signing/serialization/detached-payload-verification API this gem needs is
265
+ not in a released `cose` version yet (see "Installation" above). `bundle
266
+ install` fetches it; no local checkout is required. To hack on both at once,
267
+ replace that line with `gem "cose", path: "../cose-ruby"`.
268
+ `gem build openid4vp.gemspec` is unaffected -- it only reads the gemspec's
269
+ `cose ~> 1.4` dependency, not the Gemfile's path override.
270
+
271
+ See `examples/README.md` for the Rack example app, and
272
+ `docs/superpowers/specs/2026-09-17-openid4vp-ruby-design.md` for the full
273
+ design write-up.
274
+
275
+ ## License
276
+
277
+ The gem is available as open source under the terms of the
278
+ [MIT License](LICENSE.txt).
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ class AuthorizationRequest
5
+ # OpenID4VP §5 Authorization Request validation, split out of
6
+ # authorization_request.rb to keep files within the ~250 line guideline
7
+ # (see conventions.md). Included into AuthorizationRequest, so its methods
8
+ # run with full access to the instance's private state/helpers.
9
+ module Validator
10
+ # Full OpenID4VP §5 request-level validation, in an order that reports
11
+ # the most fundamental problem first. `scope_resolver` maps a `scope`
12
+ # string to a DCQL::Query (or Hash); `signed` and `dc_api` describe the
13
+ # transport this request arrived/will be sent over.
14
+ def validate!(scope_resolver: nil, signed: false, dc_api: false)
15
+ mode = response_mode
16
+ is_dc_api = dc_api || OpenID4VP::ResponseMode.dc_api?(mode)
17
+
18
+ validate_response_type!
19
+ validate_query_presence!(scope_resolver)
20
+ validate_credential_query_meta!
21
+ validate_nonce!
22
+ validate_state_charset!
23
+ validate_response_mode!(mode)
24
+ validate_mode_specific!(mode)
25
+ validate_client_id!(is_dc_api: is_dc_api, signed: signed)
26
+ validate_request_and_request_uri!
27
+ validate_request_uri_method!
28
+ validate_holder_binding_state!(is_dc_api: is_dc_api)
29
+ validate_cross_references!
30
+ validate_expected_origins!(is_dc_api: is_dc_api, signed: signed)
31
+ validate_client_metadata!(mode)
32
+
33
+ self
34
+ end
35
+
36
+ private
37
+
38
+ def validate_response_type!
39
+ return if response_type == OpenID4VP::ResponseType::VP_TOKEN
40
+
41
+ raise OpenID4VP::InvalidRequest, "unsupported response_type"
42
+ end
43
+
44
+ def validate_query_presence!(scope_resolver)
45
+ has_dcql = !self["dcql_query"].nil?
46
+ has_scope = !self["scope"].nil?
47
+
48
+ raise OpenID4VP::InvalidRequest, "dcql_query and scope must not both be present" if has_dcql && has_scope
49
+ raise OpenID4VP::InvalidRequest, "dcql_query or scope is required" unless has_dcql || has_scope
50
+
51
+ @memo[:effective_dcql_query] = has_dcql ? dcql_query : resolve_scope(scope_resolver)
52
+ end
53
+
54
+ # OpenID4VP Appendix B: enforces the REQUIRED shape of each requested
55
+ # credential's format-specific meta parameters (e.g. vct_values,
56
+ # doctype_value, type_values).
57
+ def validate_credential_query_meta!
58
+ query = @memo[:effective_dcql_query]
59
+ return unless query
60
+
61
+ query.credentials.each do |credential_query|
62
+ OpenID4VP::DCQL::Meta.validate!(credential_query.format, credential_query.meta)
63
+ end
64
+ end
65
+
66
+ def resolve_scope(scope_resolver)
67
+ raise OpenID4VP::InvalidRequest, "scope resolution requires a scope_resolver" if scope_resolver.nil?
68
+
69
+ resolved = scope_resolver.call(scope)
70
+ raise OpenID4VP::InvalidScope, "unknown scope #{scope.inspect}" if resolved.nil?
71
+
72
+ resolved.is_a?(OpenID4VP::DCQL::Query) ? resolved : OpenID4VP::DCQL::Query.from_h(resolved)
73
+ end
74
+
75
+ def validate_nonce!
76
+ raise OpenID4VP::InvalidRequest, "nonce is required" if nonce.nil?
77
+ raise OpenID4VP::InvalidRequest, "nonce must be url-safe" unless OpenID4VP::Util.url_safe?(nonce)
78
+ end
79
+
80
+ def validate_state_charset!
81
+ return if state.nil?
82
+
83
+ raise OpenID4VP::InvalidRequest, "state must be url-safe" unless OpenID4VP::Util.url_safe?(state)
84
+ end
85
+
86
+ def validate_response_mode!(mode)
87
+ return if OpenID4VP::ResponseMode::ALL.include?(mode)
88
+
89
+ raise OpenID4VP::InvalidRequest, "unsupported response_mode"
90
+ end
91
+
92
+ def validate_mode_specific!(mode)
93
+ if OpenID4VP::ResponseMode.direct_post?(mode)
94
+ raise OpenID4VP::InvalidRequest, "response_uri is required" if response_uri.nil?
95
+ raise OpenID4VP::InvalidRequest, "redirect_uri must not be present" unless redirect_uri.nil?
96
+ elsif OpenID4VP::ResponseMode.dc_api?(mode)
97
+ raise OpenID4VP::InvalidRequest, "redirect_uri must not be present" unless redirect_uri.nil?
98
+ raise OpenID4VP::InvalidRequest, "response_uri must not be present" unless response_uri.nil?
99
+ raise OpenID4VP::InvalidRequest, "state must not be present" unless state.nil?
100
+ else
101
+ raise OpenID4VP::InvalidRequest, "response_uri must not be present" unless response_uri.nil?
102
+ raise OpenID4VP::InvalidRequest, "redirect_uri is required" if return_uri.nil?
103
+ end
104
+ end
105
+
106
+ def validate_client_id!(is_dc_api:, signed:)
107
+ if is_dc_api
108
+ if signed
109
+ if self["client_id"].nil?
110
+ raise OpenID4VP::InvalidRequest, "client_id is required for signed DC API requests"
111
+ end
112
+
113
+ client_id
114
+ end
115
+
116
+ return
117
+ end
118
+
119
+ raise OpenID4VP::InvalidRequest, "client_id is required" if self["client_id"].nil?
120
+
121
+ client_id
122
+ end
123
+
124
+ def validate_request_and_request_uri!
125
+ return unless self["request"] && self["request_uri"]
126
+
127
+ raise OpenID4VP::InvalidRequest, "request and request_uri must not both be present"
128
+ end
129
+
130
+ def validate_request_uri_method!
131
+ method = self["request_uri_method"]
132
+ return if method.nil?
133
+
134
+ raise OpenID4VP::InvalidRequest, "request_uri_method requires request_uri" if self["request_uri"].nil?
135
+ raise OpenID4VP::InvalidRequestUriMethod, "invalid request_uri_method" unless %w[get post].include?(method)
136
+ end
137
+
138
+ def validate_holder_binding_state!(is_dc_api:)
139
+ return if is_dc_api
140
+
141
+ query = @memo[:effective_dcql_query]
142
+ requires_state = query&.credentials&.any? { |cq| cq.require_cryptographic_holder_binding == false }
143
+ return unless requires_state
144
+
145
+ raise OpenID4VP::InvalidRequest, "state is required" if state.nil?
146
+ end
147
+
148
+ def validate_cross_references!
149
+ query = @memo[:effective_dcql_query]
150
+
151
+ OpenID4VP::TransactionData.validate_against_query!(transaction_data, query) if transaction_data
152
+ OpenID4VP::VerifierInfo.validate_against_query!(verifier_info, query) if verifier_info
153
+ end
154
+
155
+ def validate_expected_origins!(is_dc_api:, signed:)
156
+ origins = expected_origins
157
+ return unless is_dc_api && signed && origins.nil?
158
+
159
+ raise OpenID4VP::InvalidRequest, "expected_origins required for signed DC API requests"
160
+ end
161
+
162
+ def validate_client_metadata!(mode)
163
+ metadata = client_metadata # parsed eagerly so a malformed value always raises, regardless of mode
164
+
165
+ return unless OpenID4VP::ResponseMode.encrypted?(mode)
166
+ # A pre-registered client_id's encryption key comes from its
167
+ # out-of-band registered metadata (OpenID4VP §5.9.2), never from this
168
+ # request's own (MUST NOT be present) client_metadata, so there is
169
+ # nothing to check here for it.
170
+ return if client_id&.pre_registered?
171
+
172
+ key = metadata&.encryption_key
173
+ raise OpenID4VP::InvalidRequest, "missing encryption key" if key.nil?
174
+ end
175
+ end
176
+ end
177
+ end