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 formatdc+sd-jwt (SD-JWT VC + KB-JWT) and jwt_vc_json (a W3C VP-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.

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. 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-level revocation (status list) is out of scope for this layer: it verifies the presentation binding and each credential's proof + validity window. Apply status policy separately with :func:openvc.verify_credential on the returned credentials.

OpenID4VPError

Bases: OpenvcError

Base class for OpenID4VP vp_token verification failures.

Source code in src/openvc/openid4vp.py
96
97
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
100
101
class VpTokenMalformed(OpenID4VPError):
    """The ``vp_token`` / ``dcql_query`` shape is invalid (not the wire contract)."""

UnsupportedPresentationFormat

Bases: OpenID4VPError

A DCQL format this verifier does not implement (ldp_vc / mso_mdoc).

Source code in src/openvc/openid4vp.py
104
105
class UnsupportedPresentationFormat(OpenID4VPError):
    """A DCQL ``format`` this verifier does not implement (``ldp_vc`` / ``mso_mdoc``)."""

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

Source code in src/openvc/openid4vp.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
@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``).
    """
    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
128
129
130
131
132
133
134
135
136
@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
134
135
136
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)

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.

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 is passed to the Data Integrity path for ldp_vc presentations that reference JSON-LD contexts beyond the bundled ones (RDF cryptosuites only).

Every Presentation is cryptographically holder-bound (the KB-JWT for dc+sd-jwt, the holder signature for jwt_vc_json and ldp_vc). 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. require_holder_binding additionally requires, for the W3C VP formats (ldp_vc, jwt_vc_json), that every embedded credential was issued to that holder (credentialSubject.id == holder) — so a presenter cannot pass off a third party's credential as their own; off by default (a holder may legitimately present another party's credential).

With no credential_sets, every Credential Query is required and its absence is rejected. When the query does carry credential_sets, per-query completeness is not enforced here (a follow-up): an empty vp_token is still rejected, but the caller MUST inspect :meth:VpTokenVerification.for_query to confirm the specific credentials it needs came back.

Source code in src/openvc/openid4vp.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
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
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,
) -> 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.

    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* is passed to
    the Data Integrity path for ``ldp_vc`` presentations that reference JSON-LD
    contexts beyond the bundled ones (RDF cryptosuites only).

    Every Presentation is cryptographically holder-bound (the KB-JWT for
    ``dc+sd-jwt``, the holder signature for ``jwt_vc_json`` and ``ldp_vc``). 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. *require_holder_binding* additionally
    requires, for the W3C VP formats (``ldp_vc``, ``jwt_vc_json``), that every embedded
    credential was issued to that holder (``credentialSubject.id == holder``) — so a
    presenter cannot pass off a third party's credential as their own; off by default
    (a holder may legitimately present another party's credential).

    With no ``credential_sets``, every Credential Query is required and its absence is
    rejected. When the query *does* carry ``credential_sets``, per-query completeness
    is **not** enforced here (a follow-up): an empty ``vp_token`` is still rejected, but
    the caller MUST inspect :meth:`VpTokenVerification.for_query` to confirm the
    specific credentials it needs came back.
    """
    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] = []
    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))
    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)

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

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

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). Experimental: track ISO 18013-7 / OpenID4VP as the handover finalises.

Source code in src/openvc/openid4vp.py
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
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). **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
50
51
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
54
55
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
58
59
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
62
63
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
 93
 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
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")

    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 {alg!r} is not permitted (only ECDH-ES)")
    if not isinstance(enc, str) or enc not in ALLOWED_ENC:
        raise UnsupportedJweAlgorithm(
            f"JWE enc {enc!r} 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