OpenID4VP presentation verification
A stateless, read/verify-only verifier for an OpenID4VP 1.0 vp_token: validate the
response shape, route each Presentation to the matching suite, and enforce the holder
binding (the request nonce and the full, prefixed client_id).
openvc.openid4vp
openvc.openid4vp — verify an OpenID4VP 1.0 vp_token response (stateless).
A read/verify-only verifier for the presentation half of OpenID for Verifiable
Presentations 1.0 (Final, 2025-07-09). Given the vp_token a wallet returned, the
dcql_query the verifier sent, and the request's nonce + client_id, it:
- validates the response shape —
vp_tokenis a JSON object keyed by DCQL Credential Queryid; each value is an array of one or more Presentations (OpenID4VP 1.0 §8.1). Unknown keys, non-array values, and a single-valued query returning more than one Presentation are rejected; - routes each Presentation to the matching proof suite by the query's
format—dc+sd-jwt(SD-JWT VC + KB-JWT) andjwt_vc_json(a W3C VP-JWT, or — when the query opts out of holder binding — a bare VC-JWT); and - verifies each Presentation's proof and its holder binding: the transaction
nonceand the audience (OpenID4VP 1.0 §14.2). The audience is either the full, prefixed Client Identifier (e.g.x509_san_dns:client.example.org, the redirect /direct_postflow — pass client_id), or — over the W3C Digital Credentials API — the calling weborigin:<origin>(Appendix A; pass expected_origins). Exactly one is given. Unless the Credential Query setsrequire_cryptographic_holder_bindingtofalse, every credential a W3C presentation carries must also be bound to its authenticated holder (§B.1). A query that sets it tofalsemay be answered with a bare Verifiable Credential instead, which carries no audience, nonce or holder (§B.1; see :func:verify_vp_token).
This is deliberately not an OpenID4VP framework: it builds no Authorization
Request, hosts no request_uri, and keeps no session/state — the verifier owns the
nonce/client_id it issued and passes them in. Encrypted responses
(direct_post.jwt, a JWE) are a separate concern (issue #19); decrypt first, then
hand the plaintext vp_token object here — both transports converge on the same
shape (§8.3).
Scope of the credential formats: dc+sd-jwt, jwt_vc_json and ldp_vc are
verified. An ldp_vc credential is presented as a W3C Verifiable Presentation
secured with a Data Integrity proof (OpenID4VP 1.0 §B.1): the holder binding is the
proof's authentication purpose with challenge = the request nonce and
domain = the (full, prefixed) client_id; the presentation's embedded
credentials are cascade-verified through :func:openvc.verify_credential. The RDF
cryptosuites (eddsa-rdfc-2022 / ecdsa-rdfc-2019) need the [data-integrity]
extra (pyld); the JCS ones (eddsa-jcs-2022 / ecdsa-jcs-2019) do not. With
holder binding waived, an ldp_vc answer may instead be a bare Data Integrity
credential.
mso_mdoc (ISO 18013-5 mdoc) is verified over the W3C Digital Credentials API
flow via :mod:openvc.mdoc: pass trust_anchors (the IACA roots) and expected_origins,
and the holder binding is the DeviceAuth over the origin-bound SessionTranscript
this module builds. It ships experimental (ADR-0005) until interop-tested against the
EUDI reference wallet; the redirect / direct_post mdoc handover is not yet wired.
Credential status (revocation / suspension) is checked when the caller passes
resolve_status_list / resolve_status_list_token: every dc+sd-jwt,
jwt_vc_json and ldp_vc credential, nested ones included, goes through
:func:openvc.verify_credential's status check. Without a resolver a declared status
is skipped unless require_status is set, which defaults to False here (unlike
:class:~openvc.VerificationPolicy, which fails closed by default). An mso_mdoc's
status is not checked by this layer.
OpenID4VPError
Bases: OpenvcError
Base class for OpenID4VP vp_token verification failures.
Source code in src/openvc/openid4vp.py
119 120 | |
VpTokenMalformed
Bases: OpenID4VPError
The vp_token / dcql_query shape is invalid (not the wire contract).
Source code in src/openvc/openid4vp.py
123 124 | |
VpTokenIncomplete
Bases: VpTokenMalformed
The vp_token is well-formed but does not answer what the dcql_query requires
(OpenID4VP 1.0 §6.4.2, §8.6): a Credential Query is missing when the query has no
credential_sets (every query is required), or no option of a required Credential
Set Query has every one of its Credential Query ids answered. A subclass of
:class:VpTokenMalformed, so an except VpTokenMalformed keeps catching it. Raised
before any Presentation is read, so it says nothing about the Presentations.
Source code in src/openvc/openid4vp.py
127 128 129 130 131 132 133 | |
UnsupportedPresentationFormat
Bases: OpenID4VPError
A DCQL format, flow or proof this verifier does not implement: an unknown
format, an ldp_vc presentation with an unsupported cryptosuite or several
proofs, or an mso_mdoc outside the Digital Credentials API flow.
Source code in src/openvc/openid4vp.py
136 137 138 139 | |
VerifiedPresentation
dataclass
One verified Presentation from the vp_token.
credentials is the tuple of verified credentials the Presentation carries — one
:class:~openvc.VerificationResult for dc+sd-jwt (the SD-JWT VC itself), and
the embedded credentials for a jwt_vc_json VP-JWT. raw is the underlying
format-specific object (VerifiedSdJwt or the VP-JWT VerifiedPresentation).
A bare credential (a jwt_vc_json / ldp_vc query that waived holder
binding, answered with a Verifiable Credential and no presentation) reports
holder=None, as mso_mdoc does: nothing authenticated a holder. credentials
is the one verified credential and raw its suite's own result (VerifiedCredential).
Source code in src/openvc/openid4vp.py
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | |
VpTokenVerification
dataclass
The result of verifying a whole vp_token: every Presentation verified and
bound to the request's nonce + client_id.
Source code in src/openvc/openid4vp.py
167 168 169 170 171 172 173 174 175 | |
for_query(query_id)
The verified Presentation(s) returned for a DCQL Credential Query id.
Source code in src/openvc/openid4vp.py
173 174 175 | |
verify_vp_token(vp_token, *, dcql_query, nonce, client_id=None, expected_origins=None, trust_anchors=None, mdoc_jwk_thumbprint=None, resolver=None, now=None, leeway_s=DEFAULT_LEEWAY_S, extra_contexts=None, require_holder_binding=False, jwt_vc_issuer_fetch=None, x5c_trust_anchors=None, resolve_status_list=None, resolve_status_list_token=None, require_status=False, kb_max_age_s=DEFAULT_KB_MAX_AGE_S)
Verify an OpenID4VP 1.0 vp_token against the query and request binding.
vp_token is the response object (or its JSON string). dcql_query is the
dcql_query sent in the Authorization Request. nonce is the request's
transaction nonce, bound on every Presentation.
Pass exactly one of client_id or expected_origins — the audience the holder binding must match:
- client_id — the redirect /
direct_postflow: the full, prefixed Client Identifier (e.g.x509_san_dns:verifier.example), compared verbatim against the KB-JWT / VP-JWTaud. - expected_origins — the W3C Digital Credentials API flow (
dc_apiresponse mode): a DC-API-delivered response binds to the calling web origin, so per OpenID4VP 1.0 Appendix A the audience is alwaysorigin:<origin>(never the client_id). Pass the origins your verifier serves (e.g.["https://verifier.example"]); a Presentation is accepted only if its signedaudisorigin:<o>for an o in the list. CIR (EU) 2025/1569 pins remote presentation to OpenID4VP + the DC API.
resolver resolves issuer/holder keys (a :class:~openvc.did.base.DidResolverRegistry);
now pins the evaluation instant for the validity window, and for the SD-JWT VC
Key Binding JWT's freshness window: its iat must be no older than
kb_max_age_s (default 300 s) plus leeway_s, and not in the future (RFC 9901
§7.3 step 5.5). It applies to SD-JWT VCs nested in jwt_vc_json / ldp_vc
presentations too. To verify an archived response, pin now to the receipt time
your system recorded — never to a value read from the response itself. A naive
now is UTC. leeway_s is the clock-skew tolerance every lane adds to its temporal
checks: a whole number of seconds from 0 to one day (86 400); anything else is an
:class:OpenID4VPError, raised before the response is read.
An mso_mdoc Presentation (ISO 18013-5, experimental) is verified over the
Digital Credentials API flow: pass trust_anchors (the IACA root
:class:~cryptography.x509.Certificate objects the document signer must chain to) and
expected_origins; the value is a base64url DeviceResponse and the DeviceAuth
is bound to the origin-bound SessionTranscript (:func:dcapi_session_transcript),
tried against each expected origin. mdoc_jwk_thumbprint is the verifier's
response-encryption key thumbprint for that transcript (None when unencrypted).
The verified :class:VerifiedPresentation carries one :class:openvc.mdoc.VerifiedMdoc
per document in credentials.
Returns a :class:VpTokenVerification. Raises :class:VpTokenMalformed on a
shape violation, :class:UnsupportedPresentationFormat for an ldp_vc presentation
whose Data Integrity cryptosuite is not one of the whole-document suites (or an
mso_mdoc outside the Digital Credentials API flow), a typed
:class:openvc.mdoc.MdocError if an mdoc fails to verify or bind, and the suite's own
typed error (SignatureInvalid /
ClaimsInvalid / …) if any Presentation fails to verify or bind — a single
failure rejects the whole response (fail closed). extra_contexts supplies JSON-LD
contexts beyond the bundled ones (url -> document; nothing is fetched): the Data
Integrity path uses them for ldp_vc presentations (RDF cryptosuites), and the
meta.type_values match below uses them for both W3C formats.
A Credential Query's DCQL constraint on the credential type is enforced per format:
meta.vct_values (dc+sd-jwt), meta.doctype_value (mso_mdoc) and
meta.type_values (jwt_vc_json / ldp_vc). For type_values every
credential the presentation embeds must have, among its fully expanded types, all the
IRIs of at least one alternative (OpenID4VP 1.0 §B.1.1), else ClaimsInvalid. The
types are expanded with JSON-LD against the credential's own @context, so the
query names IRIs: VerifiableCredential expands to
https://www.w3.org/2018/credentials#VerifiableCredential, while a term no context
defines stays as written. Expanding needs the [data-integrity] extra (pyld)
and every context the credential cites — only the VCDM 2.0 context is bundled. A
nested SD-JWT never matches. A query with no meta, or whose meta lacks the
key, puts no constraint on the type. A malformed constraint is
:class:VpTokenMalformed, for all three formats and before any Presentation is
verified: a meta that is not a JSON object, or a constraint that is present
but null or the wrong shape, never reads as "no constraint". Every query is
checked, answered or not (a query the wallet left out under credential_sets
included), so one malformed alternative fails on every response, not only on the
day a wallet answers it. A format openvc does not know, and the keys of another
format, are not read. A well-formed type_values needs pyld only when its
query is answered.
Like vct_values, the check selects the right credential type, not a trusted
issuer: check each result's issuer yourself.
Every Presentation is cryptographically holder-bound (the KB-JWT for
dc+sd-jwt, the holder signature for jwt_vc_json and ldp_vc), unless a W3C
query waives it and the answer is a bare credential (below). For the W3C
VP formats (jwt_vc_json / ldp_vc) the reported holder is the
authenticated signer — the controller of the verificationMethod whose key signed,
never a self-asserted field. For dc+sd-jwt it is the issuer-signed sub: the
KB-JWT proves the presenter holds the cnf key the issuer bound to sub, so
sub is trustworthy, though it is the issuer's identifier for the holder rather
than the KB signer's own key thumbprint.
For the W3C VP formats, the VP proof alone does not bind the credentials it carries
to the presenter. So when a Credential Query requires Cryptographic Holder
Binding — its require_cryptographic_holder_binding, default true (OpenID4VP
1.0 §6.1, §B.1) — every embedded credential must be bound to the authenticated holder,
or the response is rejected (ClaimsInvalid):
- an SD-JWT VC (bare, or in an
EnvelopedVerifiableCredential) must carry a KB-JWT bound to this exchange (key_bound=True); one with no KB-JWT, or nocnf, is rejected. The VP signer never stands in for thecnfkey; - every other credential (VC-JWT, Data Integrity) must name the holder as its
single subject (
sub/credentialSubject.id). A bearer credential (no subject id), one whosecredentialSubjectis an array, or another party's credential is rejected. Subject and holder are compared as strings, so the same key under two DID methods is two different holders.
A jwt_vc_json or ldp_vc presentation must embed at least one credential,
whatever the flag.
To accept a credential the presenter is not bound to — §14.1.1's "the Holder may not
be the subject of the Credential" — set the flag to false in that query.
A non-boolean flag is :class:VpTokenMalformed.
A bare credential (§B.1: a Wallet answers a query that waives holder binding with a
Verifiable Credential, not a presentation). A jwt_vc_json / ldp_vc query that sets the
flag to false accepts a bare Verifiable Credential or a Verifiable Presentation (a
presentation is what these lanes accepted before). With the flag true or absent a bare
credential is :class:VpTokenMalformed, on either lane, before anything is verified. The
bare form is a VC-JWT under jwt_vc_json and a Data Integrity credential under
ldp_vc (any suite :func:openvc.verify_credential knows, ecdsa-sd-2023 included),
verified in full as it is anywhere else; it never reaches the VP-JWT suite, and a bare
SD-JWT is never accepted under a W3C query. Which shape a value has is decided before
anything is verified, fail-closed: a JWT is a credential when its payload has a vc
object and no vp; a JSON object when its type has VerifiableCredential and not
VerifiablePresentation. A value that is positively both, or whose JOSE typ
(vc+jwt / vp+jwt) contradicts the claim it carries, or an
EnvelopedVerifiableCredential (not accepted bare), is :class:VpTokenMalformed;
anything else that is not positively a credential is left to the presentation lane and its
own typed error. A bare credential has no audience, nonce, challenge or domain: a
VC-JWT that carries an aud is rejected (ClaimsInvalid, RFC 7519 §4.1.3: none can
name this verifier), and its result is holder=None. Nothing binds it to this exchange,
so it can be replayed (§14.1.1) and the response carries no nonce (§5.3): the
verifier MUST bind request and response with a fresh, high-entropy state, unless the
Digital Credentials API is used. openvc keeps no state, so checking state is the
caller's job, as nonce freshness is. require_holder_binding cannot be met by a bare
credential (ClaimsInvalid). Status, meta.type_values and the credential's own
validity are checked as for any embedded credential.
The same goes for a Credential Query's multiple (OpenID4VP 1.0 §6.1, default
false): each vp_token value is an array holding exactly one Presentation
unless the query sets "multiple": true (§8.1), and a multiple that is not a
JSON boolean ("false", 1, null, [], …) is :class:VpTokenMalformed,
naming the query, for every query, answered or not.
require_holder_binding is the caller's own, stricter switch: whatever the query
says, every embedded credential's subject — an SD-JWT VC's sub included — must
be the authenticated holder.
A KB-JWT that a nested SD-JWT VC does carry must be bound to the same audience and
nonce as the presentation, whatever the flag, or the response is rejected — so a
nested result's key_bound=True means bound to this exchange. With the flag
false, a nested SD-JWT VC without one reports key_bound=False.
Completeness (OpenID4VP 1.0 §6.4.2 and §8.6). With no credential_sets, every
Credential Query is required and the absence of one raises :class:VpTokenIncomplete.
With credential_sets, every set whose required is true or omitted must be
satisfied: at least one of its options must have every Credential Query id
answered in the vp_token, else :class:VpTokenIncomplete names the set
(credential_sets[1]). A set with "required": false and a Credential Query that
no set references may be left out. This is checked on the keys of the vp_token,
before any Presentation is read, and an empty vp_token is rejected whatever the
sets say. Extra answers (several options of a set, a query outside every option) are
not refused: each Presentation is still verified in full.
:meth:VpTokenVerification.for_query returns () for a query that was left out, so
use it to learn which option of a set the wallet answered, and which optional queries
it chose to answer.
The shape of credential_sets is validated for every query set, answered or not,
after the queries and before any Presentation is read (OpenID4VP 1.0 §6.2): it is a
non-empty array of objects; each options is a non-empty array of non-empty arrays
of ids, every id naming a Credential Query of credentials; required, when
present, is a JSON boolean. Anything else (null and [] included) is
:class:VpTokenMalformed, naming the path (dcql_query.credential_sets[0].options).
purpose and unknown members are not read. The arrays must be Python lists, as for
credentials: a tuple is :class:VpTokenMalformed. Before 1.35.0 any truthy
credential_sets, whatever it held, switched the completeness check off; null,
[] and the other falsy values read as absent, with every query required.
Source code in src/openvc/openid4vp.py
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 | |
verify_encrypted_vp_response(response, *, key, dcql_query, nonce, client_id=None, expected_origins=None, trust_anchors=None, mdoc_jwk_thumbprint=None, resolver=None, now=None, leeway_s=DEFAULT_LEEWAY_S, extra_contexts=None, require_holder_binding=False, jwt_vc_issuer_fetch=None, x5c_trust_anchors=None, resolve_status_list=None, resolve_status_list_token=None, require_status=False, kb_max_age_s=DEFAULT_KB_MAX_AGE_S)
Decrypt a HAIP direct_post.jwt response (a JWE) and verify its vp_token.
response is the compact JWE from the response form field; key is the
verifier's :class:~openvc.keys.KeyAgreementKey (the private half of the
encryption key it published in client_metadata). The JWE is decrypted (direct
ECDH-ES + A128GCM / A256GCM on P-256, allow-listed before any crypto —
see :mod:openvc.jwe); the plaintext is the OpenID4VP response object, whose
vp_token is then verified exactly as :func:verify_vp_token (same nonce /
client_id binding). The response state is not checked here — match it to
your session yourself (call :func:openvc.jwe.decrypt_compact if you need the raw
response object). Raises :class:~openvc.jwe.JweError on a decryption failure and
the same errors as :func:verify_vp_token thereafter.
Source code in src/openvc/openid4vp.py
437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 | |
dcapi_session_transcript(origin, nonce, *, jwk_thumbprint=None)
Build the OpenID4VP-over-Digital-Credentials-API SessionTranscript (CBOR bytes)
that an mdoc DeviceAuth is bound to (OpenID4VP 1.0 Appendix B / ISO 18013-7):
[null, null, ["OpenID4VPDCAPIHandover", SHA-256(cbor([origin, nonce, jwk_thumbprint]))]].
origin is the calling web origin (e.g. https://verifier.example), nonce the
Authorization Request nonce, and jwk_thumbprint the SHA-256 JWK thumbprint of the
verifier's response-encryption key (None → CBOR null, for an unencrypted
response) — compute it with :func:openvc.keys.jwk_thumbprint_bytes, which returns
the raw digest this expects.
Experimental: track ISO 18013-7 / OpenID4VP as the handover finalises.
Source code in src/openvc/openid4vp.py
798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 | |
JWE decrypt (HAIP direct_post.jwt responses)
Decrypt the JWE that wraps a vp_token in a HAIP encrypted response (direct
ECDH-ES + A128GCM/A256GCM on P-256), then feed the plaintext to the verifier
above. The recipient key-agreement backend lives in openvc.keys
(KeyAgreementKey / P256KeyAgreementKey).
openvc.jwe
openvc.jwe — decrypt a JWE Compact token (JWE ECDH-ES direct key agreement).
Decrypt only. A verifier consuming a HAIP / OpenID4VP 1.0 encrypted Authorization
Response (direct_post.jwt) receives the vp_token wrapped in a JWE; this turns
that JWE back into its plaintext bytes. Generating (encrypting) a response is a wallet
concern and out of scope.
Exactly the HAIP-mandated shape is accepted, allow-listed before any crypto (the
same fail-closed stance the JWS path takes for signatures): key management
ECDH-ES (direct — the CEK is derived, there is no wrapped key), content encryption
A128GCM / A256GCM, over an ephemeral P-256 key. The ECDH runs through a
:class:~openvc.keys.KeyAgreementKey backend, so the recipient's private half can
live in an HSM/Vault. The public NIST SP 800-56A Concat KDF (RFC 7518 §4.6) and the
AES-GCM decrypt are done here.
JweError
Bases: OpenvcError
Base class for JWE decryption failures.
Source code in src/openvc/jwe.py
51 52 | |
JweMalformed
Bases: JweError
The JWE token / header is structurally invalid.
Source code in src/openvc/jwe.py
55 56 | |
UnsupportedJweAlgorithm
Bases: JweError
A JWE alg / enc outside the fail-closed allow-list.
Source code in src/openvc/jwe.py
59 60 | |
JweDecryptionFailed
Bases: JweError
The AES-GCM tag did not verify (wrong key, or tampered ciphertext).
Source code in src/openvc/jwe.py
63 64 | |
decrypt_compact(token, *, key)
Decrypt a JWE Compact token to its plaintext bytes.
Accepts only direct ECDH-ES + A128GCM / A256GCM over a P-256 ephemeral
key (allow-listed before any crypto). key is the recipient's
:class:~openvc.keys.KeyAgreementKey; the raw ECDH runs inside it, so a
Vault/HSM private half never enters the process. Raises
:class:UnsupportedJweAlgorithm for a disallowed alg/enc,
:class:JweMalformed for a bad shape/header/ephemeral key, and
:class:JweDecryptionFailed if the authentication tag does not verify.
Source code in src/openvc/jwe.py
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | |