Skip to content

Repository files navigation

OpenID4VP

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/.

Installation

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.

Cryptography

  • JWK (RFC 7517 key representation; EC, RSA and OKP incl. X25519/X448) -- the jose gem (JOSE::JWK).
  • Compact JWS (Request Object / VP Token signing: ES256/ES384/ES512/RS256/PS256/EdDSA) -- the jwt gem's JWA registry, with jwt-eddsa adding EdDSA support. jwt-eddsa brings in the ed25519 gem, a native (C extension) dependency; EdDSA request signing is optional -- skip generating/using an OKP Ed25519 signing 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, not JOSE::JWS.
  • JWE (encrypted Authorization Responses; the ECDH-ES family) -- the jose gem (JOSE::JWE). JOSE.crypto_fallback = true is set at load time, which is what makes X25519/X448 key agreement work at all without the optional rbnacl/x25519 gems: without one of those installed, the jose gem'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, install rbnacl (preferred) or x25519 so jose picks the native implementation instead.
  • COSE_Sign1 (mdoc IssuerAuth/DeviceSignature) and COSE keys -- the cose gem. Known cose-ruby limitation: 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).

Verifier quick start

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 quick start

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:.

Supported features

Client Identifier Prefixes (OpenID4VP §5.9)

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 Modes and request delivery

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).

Credential formats (Appendix B)

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.

JOSE algorithms

  • 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.

DCQL (§6, §7)

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 and Verifier Info

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.

DC API (Appendix A)

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.

Trust hooks

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
  }
}

Limitations

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 pluggable trust[:ldp_proof_verifier] hook.
  • mso_mdoc DeviceMac (only DeviceSignature is supported).
  • SIOPv2 (id_token) and response_type=code / the OAuth token endpoint.
  • SD-JWT VC type metadata resolution, and revocation/status checks.
  • QR code rendering and any UI -- create_request's url is a plain String for your own rendering.

Development

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.

License

The gem is available as open source under the terms of the MIT License.

About

OpenID for Verifiable Presentations 1.0 for Ruby

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages