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); 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.
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 | |
VpTokenMalformed
Bases: OpenID4VPError
The vp_token / dcql_query shape is invalid (not the wire contract).
Source code in src/openvc/openid4vp.py
100 101 | |
UnsupportedPresentationFormat
Bases: OpenID4VPError
A DCQL format this verifier does not implement (ldp_vc / mso_mdoc).
Source code in src/openvc/openid4vp.py
104 105 | |
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 | |
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 | |
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 | |
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_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.
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 | |
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 | |
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 | |
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 | |
JweMalformed
Bases: JweError
The JWE token / header is structurally invalid.
Source code in src/openvc/jwe.py
54 55 | |
UnsupportedJweAlgorithm
Bases: JweError
A JWE alg / enc outside the fail-closed allow-list.
Source code in src/openvc/jwe.py
58 59 | |
JweDecryptionFailed
Bases: JweError
The AES-GCM tag did not verify (wrong key, or tampered ciphertext).
Source code in src/openvc/jwe.py
62 63 | |
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 | |