OpenID4VCI key proofs
Verify the key proof a wallet sends to a Credential Endpoint, and get back the public
key it demonstrated possession of — the value
SdJwtVcProofSuite.issue(holder_jwk=...) binds the credential to.
Stateless and transport-free: no endpoint, no Authorization Server, no nonce store. Nonce single-use is injected as a required callable. See ADR-0007 for the boundary and the Issuing with OpenID4VCI guide for the flow.
openvc.openid4vci
openvc.openid4vci — verify a wallet's OpenID4VCI key proof (stateless).
The issuer-side cryptography of OpenID for Verifiable Credential Issuance 1.0 (Final, 2025-09-16). Given the Credential Request body a wallet POSTed to a Credential Endpoint, it:
- validates the request shape —
proofsis a JSON object with exactly one member, the proof type, whose value is a non-empty array of proof values (OID4VCI 1.0 §8.2);credential_identifierandcredential_configuration_idare mutually exclusive; - verifies every proof in that array as an
openid4vci-proof+jwt(App. F.1) — thetyppin, the algorithm allow-list before any crypto, unknowncrit, exactly one of thejwk/kid/x5cheader key parameters, the signature, theaudbinding to the Credential Issuer Identifier, andiatfreshness in both directions; and - enforces the invariants that only exist across the batch — one shared
nonce, consumed exactly once through the caller's store, and no two proofs bound to the same key.
It returns the wallet public key each proof demonstrated possession of, which is what
:meth:openvc.proof.sd_jwt.SdJwtVcProofSuite.issue wants as holder_jwk.
This is deliberately not an OpenID4VCI server. It builds no Credential Response,
publishes no metadata, mints no c_nonce, pre-authorized code, transaction_id or
notification_id, runs no endpoint, and integrates no Authorization Server — those
have a lifetime, a socket or a deployment policy, and belong to the issuing application
(ADR-0007). openvc handles bytes that are signed, or that must be shaped byte-exactly
per spec; anything with a lifetime belongs to your AS.
Nonce state is the caller's, injected as :data:ConsumeNonce. It is required by
default: replay is the property a key proof exists to defend, and a plain
expected_nonce string could not express "consume once, atomically" — a caller
comparing after the fact would have verified a signature and not the replay property.
The callable is invoked once per request, after every signature has verified, so an
unauthenticated attacker cannot burn nonces by spraying garbage.
Key attestations (App. D) are parsed, bound, and not trusted. Parsed:
:func:peek_key_attestation reads one without verifying it, and a proof's attestation
reaches :data:ResolveProofKeyInContext already parsed, because the key that signed an
attested proof lives inside the header and a caller must not need a second decoder to
find it. Bound: App. D's MUST — the proof is signed by a key the attestation contains —
is enforced. Not trusted: the attestation's signature is never checked and no
wallet-provider anchor is consulted, so the binding check stops no attacker (whoever
forges a proof also chooses its attestation, and simply lists their own key); it catches
an honest wallet, or the caller's own resolver, producing a key the wallet never
claimed. Which key in attested_keys a kid names is not specified by the
spec — the example uses an index, wallets also use the JWK's own kid or a
thumbprint — so that mapping is the caller's, never a guess made here.
An issuer that publishes key_attestations_required passes
require_key_attestation=True so a missing header is a structure failure —
before crypto, before the nonce is spent — rather than a verified proof whose
caller then notices the gap and burns a single-use nonce on every retry.
Scope: the jwt proof type only. The attestation proof type, di_vp and
OpenID Federation trust_chain proof keys raise a typed
:class:UnsupportedProofType. What this supports claiming is OpenID4VCI 1.0 key-proof
verification — not "issuance", and not HAIP, which additionally requires DPoP, key
attestation trust and client authentication, all of them downstream.
The same fail-closed posture covers discovery: :func:parse_credential_offer and
:func:parse_credential_issuer_metadata parse the untrusted third-party JSON a wallet
(or an issuer checking its own deployment) receives — a Credential Offer (§4.1.1) and
the Credential Issuer Metadata document (§11.2.3) — into frozen, shape-validated
dataclasses. Parsers, never builders, and never fetchers (ADR-0007 D7).
ConsumeNonce = Callable[[str], bool]
module-attribute
Atomically mark a c_nonce used, and report whether it was valid.
The Credential Issuer's nonce state is the caller's — openvc stores nothing. The
callable MUST be atomic (a Redis SET key val NX, a SQL DELETE … RETURNING):
return True only if the nonce existed, had not expired, and this call is the one
that consumed it. Return anything falsey to reject.
A read-then-write store is not sufficient: two concurrent requests would both observe
the nonce as unused. :class:openvc.cache.TtlCache is not suitable either — it
documents its own lack of single-flight, which is benign for a read cache and fatal for
a single-use token.
Invoked exactly once per Credential Request, after every proof signature has verified.
ResolveProofKey = Callable[[str], dict]
module-attribute
Map a proof JWT's kid header to the wallet's public JWK.
Injected because resolving a kid is deployment policy (a wallet-provider registry, a
prior enrolment record). Absent, a kid-keyed proof is rejected — fail closed.
Sees the kid and nothing else. When the key is carried in the header — the
attested-key form, {typ, alg, kid, key_attestation} — use
:data:ResolveProofKeyInContext instead.
ResolveProofKeyInContext = Callable[['ProofKeyContext'], dict]
module-attribute
Map a proof to the wallet's public JWK, with everything openvc knows at that point.
The same job as :data:ResolveProofKey — and mutually exclusive with it; passing both
is a caller error — but taking a :class:ProofKeyContext rather than a bare kid.
Needed for the attested-key form, where the key that signed the proof is in the header's
key_attestation and a bare kid names it under a rule only the caller's ecosystem
knows::
def resolve(ctx):
keys = ctx.key_attestation.attested_keys if ctx.key_attestation else ()
return keys[int(ctx.kid)] # or match ctx.kid against each key's own "kid"
Takes a context object, not more parameters, so growing what a resolver can see never breaks the ones already written.
Everything in the context is unverified — no signature has been checked when it is called. Use it to select a key, never to decide the key is trustworthy.
OpenID4VCIError
Bases: OpenvcError
Base class for OpenID4VCI issuer-side failures.
Source code in src/openvc/openid4vci.py
182 183 | |
CredentialRequestMalformed
Bases: OpenID4VCIError
The Credential Request shape is invalid (not the §8.2 wire contract).
Source code in src/openvc/openid4vci.py
186 187 | |
CredentialOfferMalformed
Bases: OpenID4VCIError
The Credential Offer shape is invalid (not the §4.1 wire contract).
Source code in src/openvc/openid4vci.py
190 191 | |
IssuerMetadataMalformed
Bases: OpenID4VCIError
The Credential Issuer Metadata shape is invalid (not the §11.2.3 contract).
Source code in src/openvc/openid4vci.py
194 195 | |
UnsupportedProofType
Bases: OpenID4VCIError
A proof type or key parameter this verifier does not implement.
Source code in src/openvc/openid4vci.py
198 199 | |
ProofReplayed
Bases: OpenID4VCIError
The nonce was already consumed (or the caller's store rejected it).
Distinct from :class:~openvc.proof.errors.ClaimsInvalid so a Credential Endpoint
can answer OID4VCI invalid_nonce — hand the wallet a fresh c_nonce and let
it retry — rather than rejecting the wallet outright.
Source code in src/openvc/openid4vci.py
202 203 204 205 206 207 208 | |
VerifiedProof
dataclass
One verified key proof from a Credential Request's proofs array.
public_jwk is the key the issued Credential must be bound to — hand it straight
to :meth:~openvc.proof.sd_jwt.SdJwtVcProofSuite.issue as holder_jwk.
key_attestation is the header's attestation JWT captured verbatim and
unverified (the peek_* doctrine): it must never drive a trust decision. Its
contents are :func:peek_key_attestation's to read; that the proof key is one of
its attested_keys has been checked, that the attestation itself is genuine has
not.
Source code in src/openvc/openid4vci.py
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | |
UnverifiedKeyAttestation
dataclass
A key attestation JWT (App. D) parsed without verifying it. UNTRUSTED.
Named for what it is not. Its signature has not been checked, no wallet-provider
anchor has been consulted, and key_storage / user_authentication /
status are the wallet's own claims about itself. Structure is validated,
trust is not: the shape App. D fixes is enforced so this object is predictable,
and everything a verifier would decide is left on header and claims.
The one thing openvc does with it is negative: reject a proof whose key is not in
attested_keys (App. D's MUST). See :func:peek_key_attestation.
Source code in src/openvc/openid4vci.py
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 | |
ProofKeyContext
dataclass
What openvc knows about one key proof at key-resolution time. All UNVERIFIED.
Handed to :data:ResolveProofKeyInContext. No signature has been checked yet — by
construction, since the point is to find the key that will check it — so this is
material for selecting a key and never grounds for trusting one.
header is a read-only copy: a resolver cannot reach back and change what the
rest of the verification then sees.
Source code in src/openvc/openid4vci.py
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 | |
CredentialRequest
dataclass
A shape-validated OID4VCI 1.0 §8.2 Credential Request.
Not verified: proofs holds the raw, untrusted proof values. Pass this (or
the raw body) to :func:verify_credential_request_proofs.
Source code in src/openvc/openid4vci.py
337 338 339 340 341 342 343 344 345 346 347 348 349 | |
CredentialOffer
dataclass
A shape-validated OID4VCI 1.0 §4.1.1 Credential Offer. UNTRUSTED input.
This parses bytes a third party produced; nothing in it has been authenticated —
the offer is how a wallet discovers an issuer, not proof it is talking to one.
Trust in credential_issuer comes from the metadata fetched under it and the
credentials it later signs, not from the offer itself.
grants keeps the raw object, including members openvc does not know — a caller
must be able to see what it chose not to support (the known members are the
:data:GRANT_AUTHORIZATION_CODE and :data:GRANT_PRE_AUTHORIZED_CODE constants).
raw is the whole decoded document, untouched.
Source code in src/openvc/openid4vci.py
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | |
CredentialIssuerMetadata
dataclass
A shape-validated OID4VCI 1.0 §11.2.3 Credential Issuer Metadata document.
credential_configurations_supported and raw are kept verbatim: the per-
configuration shapes are an extension point (format-specific, and profiled by
ecosystems), so narrowing them here would reject deployments the spec leaves open.
Unknown members are likewise preserved in raw, not silently dropped.
Source code in src/openvc/openid4vci.py
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | |
parse_credential_request(body, *, batch_size=None, supported_configuration_ids=None)
Validate the Credential Request wire contract and return it structured.
batch_size caps len(proofs); it defaults to 1, so an issuer that never
advertised batch_credential_issuance rejects a batch instead of minting one
credential per proof off a single grant. supported_configuration_ids, when given,
pins credential_configuration_id to what this issuer actually offers.
Raises :class:CredentialRequestMalformed on any shape violation — a malformed
request fails safe rather than being silently narrowed.
Source code in src/openvc/openid4vci.py
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 435 436 437 438 439 440 441 442 443 444 445 446 | |
parse_credential_offer(offer)
Validate the Credential Offer wire contract and return it structured.
Accepts the decoded object or a JSON string. A by-value offer is the object
itself; a by-reference credential_offer_uri is not dereferenced here —
openvc fetches nothing, so resolving one is the caller's injected Fetch (the
:mod:openvc.jwt_vc_issuer pattern).
Fail-closed rules (a malformed constraint raises rather than being ignored):
credential_issuermust be present and an absolute https URL — it is the identifier the key proof'saudis compared against, so a malformed one must never reach the verifier;credential_configuration_idsmust be a non-empty array of distinct non-empty strings;grants, when present, must be an object. Unknown members are preserved inraw, not silently dropped: a caller must see what it chose not to support.
Raises :class:CredentialOfferMalformed on any violation.
Source code in src/openvc/openid4vci.py
508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 | |
parse_credential_issuer_metadata(metadata)
Validate the Credential Issuer Metadata contract and return it structured.
Accepts the decoded object or a JSON string. Endpoint URLs, when present, must be
absolute https; authorization_servers, when present, a non-empty array of
them. batch_credential_issuance, when present, must carry an integer
batch_size ≥ 2 (a batch of one is not a batch). Everything else —
per-configuration shapes, display metadata, encryption parameters, unknown
members — is preserved verbatim in the result and in raw: this parser fixes
the members a caller acts on and stays out of the extension points.
Raises :class:IssuerMetadataMalformed on any violation.
Source code in src/openvc/openid4vci.py
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 | |
peek_proof_header(proof)
A key proof's protected header, read without verifying anything. UNTRUSTED.
Exposed so that a caller who must look at a proof before verification — to pick a
registry, to find the key_attestation — uses this parse rather than writing a
second one. Two notions of what a header is can disagree; one cannot.
Read-only, and never a trust decision: the bytes are the wallet's, unauthenticated.
Source code in src/openvc/openid4vci.py
699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 | |
peek_key_attestation(attestation)
Parse a key attestation JWT (OID4VCI 1.0 App. D) without verifying it.
Returns an :class:UnverifiedKeyAttestation — read its docstring before using
anything it holds. Structure is validated, trust is not: attested_keys must
be a non-empty array of JWK objects and the other App. D members must have their
documented types, because a caller reading a predictable object is the whole point;
but typ, exp and the signature are not checked, because those are a
verifier's decisions and that verifier needs a wallet-provider trust anchor openvc
has no model for (ADR-0007 D9).
Raises :class:~openvc.proof.errors.MalformedToken if it is not a compact JWS, and
:class:~openvc.proof.errors.ClaimsInvalid if it is one but not shaped like a key
attestation.
Source code in src/openvc/openid4vci.py
716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 | |
verify_credential_request_proofs(request, *, credential_issuer, check_nonce=None, require_nonce=True, expected_client_id=None, resolve_proof_key=None, resolve_proof_key_in_context=None, trust_anchors=None, require_key_attestation=False, max_age_s=DEFAULT_PROOF_MAX_AGE_S, leeway_s=DEFAULT_LEEWAY_S, now=None, allowed_algs=ALLOWED_ALGS, batch_size=None)
Verify every key proof in a Credential Request; return what each demonstrated.
credential_issuer is the Credential Issuer Identifier each proof's aud must
equal. check_nonce consumes the c_nonce (see :data:ConsumeNonce) and is
required unless require_nonce is explicitly False. resolve_proof_key — or
resolve_proof_key_in_context, which additionally sees the header and its parsed
key attestation, and which is the one the attested-key form needs — and
trust_anchors enable the kid and x5c key parameters respectively; without
them, proofs using those parameters are rejected. Passing both resolvers is a caller
error. expected_client_id, when given, pins the proof's iss.
require_key_attestation refuses a missing key_attestation header before
crypto and before the nonce is spent; default False. now pins the
instant for deterministic tests.
When a proof carries key_attestation, App. D's MUST is enforced: the key that
signed it must be one of the attestation's attested_keys. That check stops no
attacker — the attestation is unsigned as far as openvc is concerned, so a forger
lists their own key — and exists to catch an honest wallet, or this call's own
resolver, producing a key the wallet never claimed. Trusting the attestation is
downstream work and needs a wallet-provider anchor.
Any failure rejects the whole request — there is no partial issuance. Raises
:class:CredentialRequestMalformed, :class:UnsupportedProofType,
:class:ProofReplayed, or the shared proof errors
(:class:~openvc.proof.errors.ClaimsInvalid,
:class:~openvc.proof.errors.SignatureInvalid,
:class:~openvc.proof.errors.MalformedToken,
:class:~openvc.proof.errors.UnsupportedAlgorithm).
Source code in src/openvc/openid4vci.py
804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 | |