OpenID for Verifiable Presentations 1.0
for Ruby: a framework-agnostic Verifier and Wallet implementation
covering Authorization Requests (JAR/RFC 9101, all Client Identifier
Prefixes), DCQL credential queries, every Response Mode (including
encrypted responses and the W3C Digital Credentials API), and the
dc+sd-jwt, jwt_vc_json, mso_mdoc and ldp_vc Credential Formats. A
plain Rack example Verifier app is included under examples/.
Add this line to your application's Gemfile:
gem "openid4vp"Then bundle install. Ruby >= 3.2 is required. Runtime dependencies:
cbor, cose (~> 1.4), jose, jwt and jwt-eddsa -- see "Cryptography"
below for what each one is used for; X.509 parsing/chain validation stays on
stdlib openssl. openssl-signature_algorithm is not a direct dependency of
this gem; it comes in transitively via cose.
cose 1.4 has not been released yet. This gem needs
COSE::Sign1#sign/#serialize and detached-payload #verify, which
currently only exist on the sign1-signing branch of
cedarcode/cose-ruby
-- see "Development" below for how to build against it in the meantime. Publishing openid4vp for real requires releasing cose 1.4.0
first.
- JWK (RFC 7517 key representation; EC, RSA and OKP incl.
X25519/X448) -- the
josegem (JOSE::JWK). - Compact JWS (Request Object / VP Token signing:
ES256/ES384/ES512/RS256/PS256/EdDSA) -- thejwtgem's JWA registry, withjwt-eddsaaddingEdDSAsupport.jwt-eddsabrings in theed25519gem, a native (C extension) dependency; EdDSA request signing is optional -- skip generating/using an OKPEd25519signing key if you'd rather not carry that native dependency. - JWS JSON Serialization (DC API multisigned requests, Appendix A) --
built on the same
jwt-backed sign/verify primitives as compact JWS above, notJOSE::JWS. - JWE (encrypted Authorization Responses; the
ECDH-ESfamily) -- thejosegem (JOSE::JWE).JOSE.crypto_fallback = trueis set at load time, which is what makes X25519/X448 key agreement work at all without the optionalrbnacl/x25519gems: without one of those installed, thejosegem's ECDH-ES for OKP (X25519/X448) recipients runs through its pure-Ruby fallback implementation, which is not constant-time (timing side-channel risk). The default and most common case -- EC (P-256/384/521) recipients -- is unaffected; this only matters if you configure a static X25519/X448 recipient key. If you do, installrbnacl(preferred) orx25519sojosepicks the native implementation instead. - COSE_Sign1 (mdoc IssuerAuth/DeviceSignature) and COSE keys -- the
cosegem. Knowncose-rubylimitation: verification re-encodes the protected header from the parsed map rather than reusing the original bytes, so a foreign mdoc with non-canonical protected-header CBOR may fail to verify even though it is spec-valid. - X.509 certificate parsing and chain validation -- stdlib
openssl(unchanged; not delegated to a gem).
require "openid4vp"
verifier = OpenID4VP::Verifier.new(
client_id: "x509_san_dns:verifier.example",
response_uri: "https://verifier.example/oid4vp/response",
request_uri_base: "https://verifier.example/oid4vp/request", # request_uri = "#{request_uri_base}/#{request_id}"
redirect_uri_base: "https://verifier.example/oid4vp/callback", # enables the response_code redirect (OpenID4VP §8.2)
signing: { key: verifier_jwk, x5c: [verifier_cert_pem] }, # nil for the redirect_uri prefix
encryption: { ephemeral: true }, # for *.jwt response modes; any Hash without :keys is ephemeral --
# `ephemeral: true` is optional sugar, not itself inspected -- or
# { keys: [...] } for a static key
trust: {
sd_jwt_issuer_keys: ->(iss, _header) { issuer_jwk_for(iss) },
mdoc_trust_anchors: [mdl_ca_cert]
}
)
# OpenID4VP §5, §13.3 steps 2-4: build and store the Authorization Request.
created = verifier.create_request(
dcql_query: { "credentials" => [
{ "id" => "pid", "format" => "dc+sd-jwt", "meta" => { "vct_values" => ["https://credentials.example.com/pid"] },
"claims" => [{ "path" => ["given_name"] }] }
] },
response_mode: "direct_post.jwt",
request_uri_method: "post"
)
created.session_id # stash this -- it is what you poll fetch_response with
created.url # hand this to the Wallet (render as a QR code, or use as a same-device deep link)
# Wire your framework's routes to the endpoints (see examples/verifier_app.rb
# for a complete plain-Rack controller):
verifier.handle_request_uri(request_id, method: "GET", params: {}, headers: {}) # OpenID4VP §5.10
verifier.handle_response(params, headers: {}) # OpenID4VP §8.2
# Once the Wallet has responded (§8.6/§14.1); response_code is REQUIRED
# whenever redirect_uri_base is configured, and returns nil while pending:
result = verifier.fetch_response(created.session_id, response_code: response_code)
result&.claims # => {"pid" => [{"given_name" => "Erika"}]}Verifier.new accepts (see lib/openid4vp/verifier/config.rb for the full,
authoritative list): client_id:, response_uri:, redirect_uri:,
request_uri_base:, redirect_uri_base:, authorization_endpoint:,
signing:, attestation_jwt:, encryption:, vp_formats_supported:,
client_metadata:, session_store: (defaults to the in-process
MemorySessionStore; supply your own for a multi-process deployment),
trust:, formats:, transaction_data_types:, scope_resolver:,
session_ttl:, clock:, verify_on_receipt:.
wallet = OpenID4VP::Wallet.new(
metadata: OpenID4VP::Metadata::WalletMetadata.new(
"response_types_supported" => ["vp_token"],
"vp_formats_supported" => { "dc+sd-jwt" => { "sd-jwt_alg_values" => ["ES256"], "kb-jwt_alg_values" => ["ES256"] } },
"client_id_prefixes_supported" => %w[x509_san_dns redirect_uri pre-registered]
),
verification: {
x509_trust_anchors: [verifier_ca_cert],
pre_registered_clients: { "client-1" => { metadata: {...}, jwks: {...}, redirect_uris: [...] } }
}
)
# The one-call convenience (processes, evaluates, builds and submits):
submission = wallet.respond(url_or_params, credentials) # credentials: Array of OpenID4VP::Credential
# Or step by step, e.g. to let the user pick among multiple matches:
processed = wallet.process_request(url_or_params) # or wallet.process_dc_api_request(protocol, data, origin:)
selection = wallet.evaluate(processed, credentials) # DCQL::Selection
built = wallet.build_response(processed, selection) # raises AccessDenied when not selection.satisfiable?
submission = wallet.submit(processed, built) # Submission(kind: :posted/:redirect/:dc_api, redirect_uri:, data:, status:)Wallet.new accepts metadata:, http: (defaults to a Net::HTTP-based
HttpClient; inject your own adapter -- anything responding to
get(url, headers:) / post(url, body:, headers:) -> Response(status:, headers:, body:) -- for testing or a different HTTP stack), verification:
(a Hash normalized into RequestVerification::Config, or an instance
directly), formats:, transaction_data_types:, scope_resolver:,
clock:, holder_keys:.
| Prefix | Notes |
|---|---|
redirect_uri |
Requests MUST NOT be signed; the Verifier metadata comes entirely from client_metadata. |
x509_san_dns |
Request signed and carries x5c; the leaf certificate's subjectAltName DNS entry must equal the client_id. |
x509_hash |
Like x509_san_dns, but the client_id is the leaf certificate's base64url hash. |
verifier_attestation |
Signed with a key attested by a trusted issuer's Verifier Attestation JWT (typ: verifier-attestation+jwt). |
decentralized_identifier |
Signed with a key resolved from a DID document via an injected did_resolver: callback. |
| pre-registered (no prefix) | Metadata/keys looked up from pre_registered_clients:; MUST NOT carry client_metadata. |
origin |
DC API only, wallet-derived from the calling origin; rejected if present in a request. |
openid_federation |
Parsed; trust-chain resolution is a pluggable federation_resolver: hook, not implemented. |
| Response Mode | Notes |
|---|---|
fragment / query |
Unencrypted redirect; the Wallet builds a URL for the caller's HTTP layer to redirect to. |
direct_post / direct_post.jwt |
Wallet POSTs the response to response_uri; .jwt encrypts it (JWE). |
dc_api / dc_api.jwt |
W3C Digital Credentials API; .jwt encrypts. Requests can be unsigned, signed, or multisigned (Appendix A). |
Requests can be delivered by value (request), or by reference
(request_uri) with request_uri_method get or post (the post
variant lets the Wallet send wallet_metadata/wallet_nonce so the
Verifier can tailor encryption/format/signing-alg to it, per §5.10).
| Format | What is verified |
|---|---|
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. |
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. |
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. |
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. |
Unknown formats are pluggable via OpenID4VP::Formats::Registry.
- Signing (JWS / Request Objects):
ES256,ES384,ES512,RS256,PS256,EdDSA. - Key management (JWE, encrypted responses):
ECDH-ES,ECDH-ES+A128KW,ECDH-ES+A256KW. - Content encryption (JWE):
A128GCM(default),A256GCM,A128CBC-HS256.
Full data model and validation; claims path pointer processing for both
JSON and mdoc namespace semantics; the §6.4 selection algorithm --
claims, claim_sets, credential_sets (including optional sets),
values matching, and trusted_authorities (built-in aki matching, with
etsi_tl/openid_federation as pluggable trust[:authority_hooks]
hooks) -- plus require_cryptographic_holder_binding: false support.
Transaction Data parsing (§5.1, §8.4) with a pluggable type registry and the
SD-JWT transaction_data_hashes profile (§B.3.3); Verifier Info parsing
(§5.11, §12); Verifier Attestation JWT issuance and validation.
Request shapes for openid4vp-v1-unsigned, -signed and -multisigned
(JWS JSON serialization with a per-signature client_id), expected_origins
checking, origin: as the presentation audience, and dc_api/dc_api.jwt
responses.
Presentation and authority verification that depends on your PKI/issuer
registry is injected via trust: (on both Verifier.new and, per format
handler, threaded through OpenID4VP::Formats::Context):
trust: {
sd_jwt_issuer_keys: ->(iss, header) { ... }, # (iss, jws_header) -> JWK | [JWK]
jwt_vc_issuer_keys: ->(iss, header) { ... },
jwt_vc_holder_keys: ->(iss, header) { ... },
mdoc_trust_anchors: [ca_cert, ...], # Array of OpenSSL::X509::Certificate
ldp_proof_verifier: ->(vp_hash, context) { ... }, # (vp_hash, Formats::Context) -> bool
authority_hooks: { # OpenID4VP §6.1.1 trusted_authorities the built-in
"etsi_tl" => ->(ta_query, credential) { ... } # "aki" matcher can't check
}
}Out of scope for this gem (design doc §1.3), plus two implemented-but-partial credential-format cases:
- OpenID Federation trust-chain resolution (the prefix is parsed; supply
your own
federation_resolver:/trust[:authority_hooks]["openid_federation"]). - DID resolution (a
did_resolver:callback is accepted, not implemented). - ETSI Trusted Lists resolution (
trust[:authority_hooks]["etsi_tl"]is a hook). - JSON-LD Data Integrity proof cryptography for
ldp_vc-- no JSON-LD canonicalization is implemented; verification is a pluggabletrust[:ldp_proof_verifier]hook. mso_mdocDeviceMac (only DeviceSignature is supported).- SIOPv2 (
id_token) andresponse_type=code/ the OAuth token endpoint. - SD-JWT VC type metadata resolution, and revocation/status checks.
- QR code rendering and any UI --
create_request'surlis a plain String for your own rendering.
bundle install
bundle exec rake # runs the full RSpec suite and RuboCop
The Gemfile points the cose gem at the sign1-signing branch of
cedarcode/cose-ruby via a github: source, since the COSE::Sign1
signing/serialization/detached-payload-verification API this gem needs is
not in a released cose version yet (see "Installation" above). bundle install fetches it; no local checkout is required. To hack on both at once,
replace that line with gem "cose", path: "../cose-ruby".
gem build openid4vp.gemspec is unaffected -- it only reads the gemspec's
cose ~> 1.4 dependency, not the Gemfile's path override.
See examples/README.md for the Rack example app, and
docs/superpowers/specs/2026-09-17-openid4vp-ruby-design.md for the full
design write-up.
The gem is available as open source under the terms of the MIT License.