Skip to content

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:

  1. validates the response shape — vp_token is a JSON object keyed by DCQL Credential Query id; 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;
  2. routes each Presentation to the matching proof suite by the query's format — dc+sd-jwt (SD-JWT VC + KB-JWT) and jwt_vc_json (a W3C VP-JWT, or — when the query opts out of holder binding — a bare VC-JWT); and
  3. verifies each Presentation's proof and its holder binding: the transaction nonce and 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_post flow — pass client_id), or — over the W3C Digital Credentials API — the calling web origin:<origin> (Appendix A; pass expected_origins). Exactly one is given. Unless the Credential Query sets require_cryptographic_holder_binding to false, every credential a W3C presentation carries must also be bound to its authenticated holder (§B.1). A query that sets it to false may 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
class OpenID4VPError(OpenvcError):
    """Base class for OpenID4VP ``vp_token`` verification failures."""

VpTokenMalformed

Bases: OpenID4VPError

The vp_token / dcql_query shape is invalid (not the wire contract).

Source code in src/openvc/openid4vp.py
123
124
class VpTokenMalformed(OpenID4VPError):
    """The ``vp_token`` / ``dcql_query`` shape is invalid (not the wire contract)."""

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
class VpTokenIncomplete(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."""

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
class UnsupportedPresentationFormat(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."""

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
@dataclass(frozen=True)
class VerifiedPresentation:
    """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``).
    """
    query_id: str
    format: str
    holder: str | None
    credentials: tuple[Any, ...]
    raw: Any

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
@dataclass(frozen=True)
class VpTokenVerification:
    """The result of verifying a whole ``vp_token``: every Presentation verified and
    bound to the request's ``nonce`` + ``client_id``."""
    presentations: tuple[VerifiedPresentation, ...]

    def for_query(self, query_id: str) -> tuple[VerifiedPresentation, ...]:
        """The verified Presentation(s) returned for a DCQL Credential Query *id*."""
        return tuple(p for p in self.presentations if p.query_id == query_id)

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
def for_query(self, query_id: str) -> tuple[VerifiedPresentation, ...]:
    """The verified Presentation(s) returned for a DCQL Credential Query *id*."""
    return tuple(p for p in self.presentations if p.query_id == query_id)

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_post flow: the full, prefixed Client Identifier (e.g. x509_san_dns:verifier.example), compared verbatim against the KB-JWT / VP-JWT aud.
  • expected_origins — the W3C Digital Credentials API flow (dc_api response mode): a DC-API-delivered response binds to the calling web origin, so per OpenID4VP 1.0 Appendix A the audience is always origin:<origin> (never the client_id). Pass the origins your verifier serves (e.g. ["https://verifier.example"]); a Presentation is accepted only if its signed aud is origin:<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 no cnf, is rejected. The VP signer never stands in for the cnf key;
  • 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 whose credentialSubject is 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
def verify_vp_token(
    vp_token: Mapping[str, Any] | str,
    *,
    dcql_query: Mapping[str, Any],
    nonce: str,
    client_id: str | None = None,
    expected_origins: Sequence[str] | None = None,
    trust_anchors: Sequence[Any] | None = None,
    mdoc_jwk_thumbprint: bytes | None = None,
    resolver: Any = None,
    now: datetime | None = None,
    leeway_s: int = DEFAULT_LEEWAY_S,
    extra_contexts: Mapping[str, dict] | None = None,
    require_holder_binding: bool = False,
    jwt_vc_issuer_fetch: Any = None,
    x5c_trust_anchors: Any = None,
    resolve_status_list: Any = None,
    resolve_status_list_token: Any = None,
    require_status: bool = False,
    kb_max_age_s: int = DEFAULT_KB_MAX_AGE_S,
) -> VpTokenVerification:
    """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_post`` flow: the **full, prefixed** Client
      Identifier (e.g. ``x509_san_dns:verifier.example``), compared verbatim against the
      KB-JWT / VP-JWT ``aud``.
    * *expected_origins* — the **W3C Digital Credentials API** flow (``dc_api`` response
      mode): a DC-API-delivered response binds to the **calling web origin**, so per
      OpenID4VP 1.0 Appendix A the audience is always ``origin:<origin>`` (never the
      client_id). Pass the origins your verifier serves (e.g. ``["https://verifier.example"]``);
      a Presentation is accepted only if its signed ``aud`` is ``origin:<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 no
      ``cnf``, is rejected. The VP signer never stands in for the ``cnf`` key;
    * 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 whose ``credentialSubject`` is 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.
    """
    # Validate the duration knobs once, up front — a NaN leeway would switch every window
    # off, and a lane with no SD-JWT in it would otherwise never look at kb_max_age_s.
    leeway_seconds(leeway_s, error=OpenID4VPError)
    non_negative_seconds(kb_max_age_s, name="kb_max_age_s", error=OpenID4VPError)
    if not nonce:
        raise ClaimsInvalid("verify_vp_token requires a non-empty nonce")
    accepted_auds = _dc_api_accepted_auds(client_id, expected_origins)
    token = _parse_vp_token(vp_token)
    if not token:
        raise VpTokenMalformed("vp_token contains no presentations")
    queries = _index_dcql(dcql_query)
    _check_completeness(token, queries, dcql_query)

    verified: list[VerifiedPresentation] = []
    try:
        for query_id, presentations in token.items():
            query = queries.get(query_id)
            if query is None:
                raise VpTokenMalformed(
                    f"vp_token key {query_id!r} is not a Credential Query id in the DCQL query")
            for presentation in _presentation_list(query_id, query, presentations):
                verified.append(_verify_one(
                    query_id, query, presentation,
                    nonce=nonce, client_id=client_id, accepted_auds=accepted_auds,
                    expected_origins=expected_origins, trust_anchors=trust_anchors,
                    mdoc_jwk_thumbprint=mdoc_jwk_thumbprint,
                    resolver=resolver, now=now, leeway_s=leeway_s, extra_contexts=extra_contexts,
                    require_holder_binding=require_holder_binding,
                    jwt_vc_issuer_fetch=jwt_vc_issuer_fetch,
                    x5c_trust_anchors=x5c_trust_anchors,
                    resolve_status_list=resolve_status_list,
                    resolve_status_list_token=resolve_status_list_token,
                    require_status=require_status, kb_max_age_s=kb_max_age_s))
    except RecursionError as exc:       # backstop: a value nested past the stack
        raise VpTokenMalformed("vp_token is nested too deeply to verify") from exc
    return VpTokenVerification(presentations=tuple(verified))

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
def verify_encrypted_vp_response(
    response: str,
    *,
    key: "KeyAgreementKey",
    dcql_query: Mapping[str, Any],
    nonce: str,
    client_id: str | None = None,
    expected_origins: Sequence[str] | None = None,
    trust_anchors: Sequence[Any] | None = None,
    mdoc_jwk_thumbprint: bytes | None = None,
    resolver: Any = None,
    now: datetime | None = None,
    leeway_s: int = DEFAULT_LEEWAY_S,
    extra_contexts: Mapping[str, dict] | None = None,
    require_holder_binding: bool = False,
    jwt_vc_issuer_fetch: Any = None,
    x5c_trust_anchors: Any = None,
    resolve_status_list: Any = None,
    resolve_status_list_token: Any = None,
    require_status: bool = False,
    kb_max_age_s: int = DEFAULT_KB_MAX_AGE_S,
) -> VpTokenVerification:
    """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.
    """
    from .jwe import decrypt_compact

    leeway_seconds(leeway_s, error=OpenID4VPError)     # before the decryption, not after it
    plaintext = decrypt_compact(response, key=key)
    try:
        payload = _json.loads(plaintext)
    except (ValueError, RecursionError) as exc:
        raise VpTokenMalformed(f"decrypted response is not valid JSON: {exc}") from exc
    if not isinstance(payload, Mapping) or "vp_token" not in payload:
        raise VpTokenMalformed("decrypted response has no vp_token member")
    return verify_vp_token(
        payload["vp_token"], dcql_query=dcql_query, nonce=nonce, client_id=client_id,
        kb_max_age_s=kb_max_age_s,
        expected_origins=expected_origins, trust_anchors=trust_anchors,
        mdoc_jwk_thumbprint=mdoc_jwk_thumbprint, resolver=resolver, now=now,
        leeway_s=leeway_s, extra_contexts=extra_contexts,
        require_holder_binding=require_holder_binding,
        jwt_vc_issuer_fetch=jwt_vc_issuer_fetch,
        x5c_trust_anchors=x5c_trust_anchors,
        resolve_status_list=resolve_status_list,
        resolve_status_list_token=resolve_status_list_token,
        require_status=require_status)

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
def dcapi_session_transcript(
    origin: str, nonce: str, *, jwk_thumbprint: bytes | None = None,
) -> bytes:
    """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."""
    from . import cbor

    handover_info = cbor.encode([origin, nonce, jwk_thumbprint])
    handover = ["OpenID4VPDCAPIHandover", hashlib.sha256(handover_info).digest()]
    return cbor.encode([None, None, handover])

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
class JweError(OpenvcError):
    """Base class for JWE decryption failures."""

JweMalformed

Bases: JweError

The JWE token / header is structurally invalid.

Source code in src/openvc/jwe.py
55
56
class JweMalformed(JweError):
    """The JWE token / header is structurally invalid."""

UnsupportedJweAlgorithm

Bases: JweError

A JWE alg / enc outside the fail-closed allow-list.

Source code in src/openvc/jwe.py
59
60
class UnsupportedJweAlgorithm(JweError):
    """A JWE ``alg`` / ``enc`` outside the fail-closed allow-list."""

JweDecryptionFailed

Bases: JweError

The AES-GCM tag did not verify (wrong key, or tampered ciphertext).

Source code in src/openvc/jwe.py
63
64
class JweDecryptionFailed(JweError):
    """The AES-GCM tag did not verify (wrong key, or tampered ciphertext)."""

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
def decrypt_compact(token: str, *, key: KeyAgreementKey) -> bytes:
    """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.
    """
    if len(token) > MAX_JWE_BYTES:
        raise JweMalformed(f"JWE token exceeds {MAX_JWE_BYTES} bytes")
    parts = token.split(".")
    if len(parts) != 5:
        raise JweMalformed("a JWE Compact token has 5 base64url parts")
    protected_b64, encrypted_key_b64, iv_b64, ciphertext_b64, tag_b64 = parts

    try:
        header = _json.loads(_b64url_decode(protected_b64))
    except (ValueError, json.JSONDecodeError, RecursionError) as exc:
        raise JweMalformed(f"invalid JWE protected header: {exc}") from exc
    if not isinstance(header, dict):
        raise JweMalformed("JWE protected header must be a JSON object")

    from .proof._verify_common import safe_repr

    alg = header.get("alg")
    enc = header.get("enc")
    # isinstance guards keep an unhashable alg/enc (a JSON list/object) from raising a
    # bare TypeError out of the `in frozenset/dict` test — fail closed and typed.
    if not isinstance(alg, str) or alg not in ALLOWED_ALG:
        raise UnsupportedJweAlgorithm(
            f"JWE alg {safe_repr(alg)} is not permitted (only ECDH-ES)")
    if not isinstance(enc, str) or enc not in ALLOWED_ENC:
        raise UnsupportedJweAlgorithm(
            f"JWE enc {safe_repr(enc)} is not permitted (only A128GCM / A256GCM)")
    if header.get("zip") is not None:                  # no decompression -> no zip bombs
        raise UnsupportedJweAlgorithm("JWE compression ('zip') is not supported")
    if "crit" in header:                               # unknown critical extensions -> reject
        raise UnsupportedJweAlgorithm("JWE 'crit' extensions are not supported")
    if encrypted_key_b64:                              # direct ECDH-ES derives the CEK
        raise JweMalformed("direct ECDH-ES must carry an empty encrypted key")

    epk = header.get("epk")
    if not isinstance(epk, dict):
        raise JweMalformed("ECDH-ES requires an 'epk' ephemeral public key in the header")
    try:
        z = key.agree(epk)                             # raw ECDH shared secret (HSM boundary)
    except KeyBackendError as exc:
        raise JweMalformed(f"invalid ephemeral public key (epk): {exc}") from exc

    try:
        apu = _b64url_decode(header["apu"]) if "apu" in header else b""
        apv = _b64url_decode(header["apv"]) if "apv" in header else b""
        iv = _b64url_decode(iv_b64)
        ciphertext = _b64url_decode(ciphertext_b64)
        tag = _b64url_decode(tag_b64)
    except (ValueError, TypeError) as exc:
        raise JweMalformed(f"invalid JWE base64url segment: {exc}") from exc
    if len(iv) != 12:                                  # JWE AES-GCM mandates a 96-bit IV
        raise JweMalformed(f"JWE AES-GCM IV must be 96-bit, got {len(iv) * 8}-bit")
    if len(tag) != 16:                                 # ...and a 128-bit authentication tag
        raise JweMalformed(f"JWE AES-GCM tag must be 128-bit, got {len(tag) * 8}-bit")

    cek = _concat_kdf(z, ALLOWED_ENC[enc] * 8, enc.encode("ascii"), apu, apv)
    aad = protected_b64.encode("ascii")                # RFC 7516 §5.1: AAD = ASCII(protected)
    try:
        return AESGCM(cek).decrypt(iv, ciphertext + tag, aad)
    except InvalidTag as exc:
        raise JweDecryptionFailed("JWE authentication tag verification failed") from exc
    except ValueError as exc:                          # e.g. wrong IV length
        raise JweDecryptionFailed(f"JWE decryption failed: {exc}") from exc