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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +278 -0
- data/lib/openid4vp/authorization_request/validator.rb +177 -0
- data/lib/openid4vp/authorization_request.rb +302 -0
- data/lib/openid4vp/authorization_response.rb +151 -0
- data/lib/openid4vp/client_id.rb +90 -0
- data/lib/openid4vp/credential.rb +51 -0
- data/lib/openid4vp/dc_api.rb +116 -0
- data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
- data/lib/openid4vp/dcql/claims_query.rb +76 -0
- data/lib/openid4vp/dcql/credential_query.rb +178 -0
- data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
- data/lib/openid4vp/dcql/evaluator.rb +228 -0
- data/lib/openid4vp/dcql/matcher.rb +79 -0
- data/lib/openid4vp/dcql/meta.rb +99 -0
- data/lib/openid4vp/dcql/query.rb +93 -0
- data/lib/openid4vp/dcql/selection.rb +45 -0
- data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
- data/lib/openid4vp/dcql.rb +10 -0
- data/lib/openid4vp/encoded_response.rb +10 -0
- data/lib/openid4vp/error_response.rb +82 -0
- data/lib/openid4vp/errors.rb +73 -0
- data/lib/openid4vp/formats/base.rb +74 -0
- data/lib/openid4vp/formats/context.rb +56 -0
- data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
- data/lib/openid4vp/formats/ldp_vc.rb +160 -0
- data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
- data/lib/openid4vp/formats/registry.rb +53 -0
- data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
- data/lib/openid4vp/formats/verified_presentation.rb +45 -0
- data/lib/openid4vp/formats.rb +12 -0
- data/lib/openid4vp/jose/algorithms.rb +46 -0
- data/lib/openid4vp/jose/jwe.rb +184 -0
- data/lib/openid4vp/jose/jwk.rb +253 -0
- data/lib/openid4vp/jose/jwk_set.rb +57 -0
- data/lib/openid4vp/jose/jws.rb +225 -0
- data/lib/openid4vp/jose.rb +8 -0
- data/lib/openid4vp/mdoc/builder.rb +132 -0
- data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
- data/lib/openid4vp/mdoc/cose_key.rb +71 -0
- data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
- data/lib/openid4vp/mdoc/device_response.rb +77 -0
- data/lib/openid4vp/mdoc/device_signed.rb +111 -0
- data/lib/openid4vp/mdoc/document.rb +44 -0
- data/lib/openid4vp/mdoc/handover.rb +51 -0
- data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
- data/lib/openid4vp/mdoc/mso.rb +133 -0
- data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
- data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
- data/lib/openid4vp/mdoc/verifier.rb +169 -0
- data/lib/openid4vp/mdoc.rb +9 -0
- data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
- data/lib/openid4vp/metadata/vp_formats.rb +112 -0
- data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
- data/lib/openid4vp/metadata.rb +10 -0
- data/lib/openid4vp/request_object.rb +152 -0
- data/lib/openid4vp/request_verification/base.rb +63 -0
- data/lib/openid4vp/request_verification/config.rb +65 -0
- data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
- data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
- data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
- data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
- data/lib/openid4vp/request_verification/resolver.rb +76 -0
- data/lib/openid4vp/request_verification/verified_request.rb +12 -0
- data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
- data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
- data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
- data/lib/openid4vp/request_verification.rb +10 -0
- data/lib/openid4vp/response_encryption.rb +51 -0
- data/lib/openid4vp/response_endpoint_result.rb +33 -0
- data/lib/openid4vp/response_mode.rb +44 -0
- data/lib/openid4vp/response_type.rb +7 -0
- data/lib/openid4vp/sd_jwt/digest.rb +30 -0
- data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
- data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
- data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
- data/lib/openid4vp/sd_jwt/parser.rb +48 -0
- data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
- data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
- data/lib/openid4vp/sd_jwt.rb +10 -0
- data/lib/openid4vp/transaction_data/registry.rb +42 -0
- data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
- data/lib/openid4vp/transaction_data.rb +138 -0
- data/lib/openid4vp/util/base64url.rb +44 -0
- data/lib/openid4vp/util/form.rb +24 -0
- data/lib/openid4vp/util/random.rb +29 -0
- data/lib/openid4vp/util/uri.rb +48 -0
- data/lib/openid4vp/util.rb +23 -0
- data/lib/openid4vp/verifier/config.rb +216 -0
- data/lib/openid4vp/verifier/created_request.rb +25 -0
- data/lib/openid4vp/verifier/http_result.rb +34 -0
- data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
- data/lib/openid4vp/verifier/request_builder.rb +216 -0
- data/lib/openid4vp/verifier/request_signer.rb +59 -0
- data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
- data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
- data/lib/openid4vp/verifier/response_validator.rb +258 -0
- data/lib/openid4vp/verifier/session.rb +109 -0
- data/lib/openid4vp/verifier/session_store.rb +48 -0
- data/lib/openid4vp/verifier/verified_response.rb +53 -0
- data/lib/openid4vp/verifier.rb +155 -0
- data/lib/openid4vp/verifier_attestation.rb +130 -0
- data/lib/openid4vp/verifier_info.rb +102 -0
- data/lib/openid4vp/version.rb +5 -0
- data/lib/openid4vp/vp_token.rb +81 -0
- data/lib/openid4vp/wallet/config.rb +49 -0
- data/lib/openid4vp/wallet/http_client.rb +73 -0
- data/lib/openid4vp/wallet/presenter.rb +90 -0
- data/lib/openid4vp/wallet/processed_request.rb +14 -0
- data/lib/openid4vp/wallet/request_processor.rb +237 -0
- data/lib/openid4vp/wallet/response_builder.rb +44 -0
- data/lib/openid4vp/wallet/submission.rb +68 -0
- data/lib/openid4vp/wallet.rb +142 -0
- data/lib/openid4vp/x509/chain_validator.rb +42 -0
- data/lib/openid4vp/x509.rb +103 -0
- data/lib/openid4vp.rb +18 -0
- 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
|