Skip to content

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:

  1. validates the request shape — proofs is 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_identifier and credential_configuration_id are mutually exclusive;
  2. verifies every proof in that array as an openid4vci-proof+jwt (App. F.1) — the typ pin, the algorithm allow-list before any crypto, unknown crit, exactly one of the jwk / kid / x5c header key parameters, the signature, the aud binding to the Credential Issuer Identifier, and iat freshness in both directions; and
  3. 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
class OpenID4VCIError(OpenvcError):
    """Base class for OpenID4VCI issuer-side failures."""

CredentialRequestMalformed

Bases: OpenID4VCIError

The Credential Request shape is invalid (not the §8.2 wire contract).

Source code in src/openvc/openid4vci.py
186
187
class CredentialRequestMalformed(OpenID4VCIError):
    """The Credential Request shape is invalid (not the §8.2 wire contract)."""

CredentialOfferMalformed

Bases: OpenID4VCIError

The Credential Offer shape is invalid (not the §4.1 wire contract).

Source code in src/openvc/openid4vci.py
190
191
class CredentialOfferMalformed(OpenID4VCIError):
    """The Credential Offer shape is invalid (not the §4.1 wire contract)."""

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
class IssuerMetadataMalformed(OpenID4VCIError):
    """The Credential Issuer Metadata shape is invalid (not the §11.2.3 contract)."""

UnsupportedProofType

Bases: OpenID4VCIError

A proof type or key parameter this verifier does not implement.

Source code in src/openvc/openid4vci.py
198
199
class UnsupportedProofType(OpenID4VCIError):
    """A proof type or key parameter this verifier does not implement."""

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

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
@dataclass(frozen=True)
class VerifiedProof:
    """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**.
    """
    public_jwk: dict[str, Any] = field(default_factory=dict)
    thumbprint: str = ""                       # RFC 7638, base64url SHA-256
    alg: str = ""
    key_source: str = ""                       # "jwk" | "kid" | "x5c"
    issued_at: int = 0                         # the proof's `iat`
    nonce: str | None = None
    client_id: str | None = None               # the proof's `iss`, when present
    key_attestation: str | None = None         # UNVERIFIED
    header: Mapping[str, Any] = field(default_factory=dict)
    claims: Mapping[str, Any] = field(default_factory=dict)

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
@dataclass(frozen=True)
class UnverifiedKeyAttestation:
    """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`.
    """
    attested_keys: tuple[Mapping[str, Any], ...] = ()
    key_storage: tuple[str, ...] = ()
    user_authentication: tuple[str, ...] = ()
    certification: str | None = None           # a URL, unfetched
    nonce: str | None = None
    status: Mapping[str, Any] | None = None
    issued_at: int | None = None               # the attestation's `iat`
    expires_at: int | None = None              # the attestation's `exp`
    header: Mapping[str, Any] = field(default_factory=dict)
    claims: Mapping[str, Any] = field(default_factory=dict)

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
@dataclass(frozen=True)
class ProofKeyContext:
    """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.
    """
    kid: str | None = None
    alg: str = ""
    header: Mapping[str, Any] = field(default_factory=dict)
    key_attestation: UnverifiedKeyAttestation | None = None
    credential_issuer: str = ""                # what this proof's `aud` must equal
    index: int = 0                             # position in the request's `proofs` array

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
@dataclass(frozen=True)
class CredentialRequest:
    """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`.
    """
    credential_configuration_id: str | None = None
    credential_identifier: str | None = None
    proof_type: str | None = None              # the single member name of `proofs`
    proofs: tuple[str, ...] = ()
    response_encryption: Mapping[str, Any] | None = None
    raw: Mapping[str, Any] = field(default_factory=dict)

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
@dataclass(frozen=True)
class CredentialOffer:
    """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.
    """
    credential_issuer: str = ""                # an absolute https URL
    credential_configuration_ids: tuple[str, ...] = ()
    grants: Mapping[str, Any] = field(default_factory=dict)
    raw: Mapping[str, Any] = field(default_factory=dict)

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
@dataclass(frozen=True)
class CredentialIssuerMetadata:
    """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.
    """
    credential_issuer: str = ""                # an absolute https URL
    credential_endpoint: str = ""              # an absolute https URL
    authorization_servers: tuple[str, ...] = ()
    nonce_endpoint: str | None = None
    deferred_credential_endpoint: str | None = None
    notification_endpoint: str | None = None
    credential_configurations_supported: Mapping[str, Any] = field(default_factory=dict)
    batch_size: int | None = None              # batch_credential_issuance.batch_size
    raw: Mapping[str, Any] = field(default_factory=dict)

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
def parse_credential_request(
    body: Mapping[str, Any] | str,
    *,
    batch_size: int | None = None,
    supported_configuration_ids: Sequence[str] | None = None,
) -> CredentialRequest:
    """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.
    """
    body = _as_mapping(body, "Credential Request")
    limit = 1 if batch_size is None else batch_size
    if limit < 1:
        raise CredentialRequestMalformed("batch_size must be at least 1")

    config_id = body.get("credential_configuration_id")
    identifier = body.get("credential_identifier")
    if (config_id is None) == (identifier is None):
        raise CredentialRequestMalformed(
            "Credential Request needs exactly one of credential_configuration_id "
            "or credential_identifier")
    for name, value in (("credential_configuration_id", config_id),
                        ("credential_identifier", identifier)):
        if value is not None and (not isinstance(value, str) or not value):
            raise CredentialRequestMalformed(f"{name} must be a non-empty string")
    if (config_id is not None and supported_configuration_ids is not None
            and config_id not in supported_configuration_ids):
        raise CredentialRequestMalformed(
            f"unsupported credential_configuration_id {config_id!r}")

    proof_type, proofs = _parse_proofs(body.get("proofs"), limit)

    encryption = body.get("credential_response_encryption")
    if encryption is not None and not isinstance(encryption, Mapping):
        raise CredentialRequestMalformed(
            "credential_response_encryption must be an object")

    return CredentialRequest(
        credential_configuration_id=config_id,
        credential_identifier=identifier,
        proof_type=proof_type,
        proofs=proofs,
        response_encryption=encryption,
        raw=body,
    )

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_issuer must be present and an absolute https URL — it is the identifier the key proof's aud is compared against, so a malformed one must never reach the verifier;
  • credential_configuration_ids must be a non-empty array of distinct non-empty strings;
  • grants, when present, must be an object. Unknown members are preserved in raw, 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
def parse_credential_offer(offer: Mapping[str, Any] | str) -> CredentialOffer:
    """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_issuer`` must be present and an absolute **https** URL — it is the
      identifier the key proof's ``aud`` is compared against, so a malformed one must
      never reach the verifier;
    * ``credential_configuration_ids`` must be a non-empty array of distinct non-empty
      strings;
    * ``grants``, when present, must be an object. Unknown members are **preserved in
      ``raw``**, not silently dropped: a caller must see what it chose not to support.

    Raises :class:`CredentialOfferMalformed` on any violation.
    """
    offer = _as_offer_mapping(offer)

    issuer = _require_https_url(
        offer.get("credential_issuer"), "credential_issuer", CredentialOfferMalformed)

    ids = offer.get("credential_configuration_ids")
    if not isinstance(ids, (list, tuple)) or not ids:
        raise CredentialOfferMalformed(
            "credential_configuration_ids must be a non-empty array")
    seen: set[str] = set()
    for config_id in ids:
        if not isinstance(config_id, str) or not config_id:
            raise CredentialOfferMalformed(
                "credential_configuration_ids entries must be non-empty strings")
        if config_id in seen:
            raise CredentialOfferMalformed(
                f"duplicate credential_configuration_id {config_id!r}")
        seen.add(config_id)

    grants = offer.get("grants")
    if grants is None:
        grants = {}
    elif not isinstance(grants, Mapping):
        raise CredentialOfferMalformed("grants must be an object when present")

    return CredentialOffer(
        credential_issuer=issuer,
        credential_configuration_ids=tuple(ids),
        grants=MappingProxyType(dict(grants)),
        raw=offer,
    )

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
def parse_credential_issuer_metadata(
    metadata: Mapping[str, Any] | str,
) -> CredentialIssuerMetadata:
    """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.
    """
    metadata = _as_metadata_mapping(metadata)

    issuer = _require_https_url(
        metadata.get("credential_issuer"), "credential_issuer", IssuerMetadataMalformed)

    endpoint = _require_https_url(
        metadata.get("credential_endpoint"), "credential_endpoint",
        IssuerMetadataMalformed)

    servers = metadata.get("authorization_servers")
    if servers is None:
        auth_servers: tuple[str, ...] = ()
    elif isinstance(servers, (list, tuple)) and servers:
        auth_servers = tuple(
            _require_https_url(
                server, "authorization_servers entry", IssuerMetadataMalformed)
            for server in servers)
    else:
        raise IssuerMetadataMalformed(
            "authorization_servers must be a non-empty array of https URLs")

    optional_endpoints: dict[str, str | None] = {}
    for name in ("nonce_endpoint", "deferred_credential_endpoint",
                 "notification_endpoint"):
        value = metadata.get(name)
        if value is not None:
            value = _require_https_url(value, name, IssuerMetadataMalformed)
        optional_endpoints[name] = value

    configs = metadata.get("credential_configurations_supported")
    if configs is None:
        configs = {}
    elif not isinstance(configs, Mapping):
        raise IssuerMetadataMalformed(
            "credential_configurations_supported must be an object when present")

    batch = metadata.get("batch_credential_issuance")
    batch_size: int | None = None
    if batch is not None:
        if not isinstance(batch, Mapping):
            raise IssuerMetadataMalformed(
                "batch_credential_issuance must be an object when present")
        size = batch.get("batch_size")
        if isinstance(size, bool) or not isinstance(size, int) or size < 2:
            raise IssuerMetadataMalformed(
                "batch_credential_issuance.batch_size must be an integer ≥ 2")
        batch_size = size

    return CredentialIssuerMetadata(
        credential_issuer=issuer,
        credential_endpoint=endpoint,
        authorization_servers=auth_servers,
        nonce_endpoint=optional_endpoints["nonce_endpoint"],
        deferred_credential_endpoint=optional_endpoints["deferred_credential_endpoint"],
        notification_endpoint=optional_endpoints["notification_endpoint"],
        credential_configurations_supported=MappingProxyType(dict(configs)),
        batch_size=batch_size,
        raw=metadata,
    )

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
def peek_proof_header(proof: str) -> Mapping[str, Any]:
    """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.
    """
    if not isinstance(proof, str):
        raise MalformedToken("key proof must be a compact JWS string")
    if len(proof.encode("utf-8")) > MAX_PROOF_BYTES:
        raise MalformedToken(f"key proof exceeds {MAX_PROOF_BYTES} bytes")
    header, _, _, _ = parse_compact(proof)
    return MappingProxyType(dict(header))

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
def peek_key_attestation(attestation: str) -> UnverifiedKeyAttestation:
    """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.
    """
    if not isinstance(attestation, str):
        raise MalformedToken("key attestation must be a compact JWS string")
    if len(attestation.encode("utf-8")) > MAX_KEY_ATTESTATION_BYTES:
        raise MalformedToken(
            f"key attestation exceeds {MAX_KEY_ATTESTATION_BYTES} bytes")
    header, claims, _, _ = parse_compact(attestation)

    keys = claims.get("attested_keys")
    if not isinstance(keys, (list, tuple)) or not keys:
        raise ClaimsInvalid("key attestation attested_keys must be a non-empty array")
    for key in keys:
        if not isinstance(key, Mapping):
            raise ClaimsInvalid("key attestation attested_keys entries must be JWK objects")

    return UnverifiedKeyAttestation(
        attested_keys=tuple(MappingProxyType(dict(key)) for key in keys),
        key_storage=_attestation_strings(claims.get("key_storage"), "key_storage"),
        user_authentication=_attestation_strings(
            claims.get("user_authentication"), "user_authentication"),
        certification=_attestation_string(claims.get("certification"), "certification"),
        nonce=_attestation_string(claims.get("nonce"), "nonce"),
        status=_attestation_object(claims.get("status")),
        issued_at=_attestation_timestamp(claims.get("iat"), "iat"),
        expires_at=_attestation_timestamp(claims.get("exp"), "exp"),
        header=MappingProxyType(dict(header)),
        claims=MappingProxyType(dict(claims)),
    )

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
def verify_credential_request_proofs(
    request: CredentialRequest | Mapping[str, Any] | str,
    *,
    credential_issuer: str,
    check_nonce: ConsumeNonce | None = None,
    require_nonce: bool = True,
    expected_client_id: str | None = None,
    resolve_proof_key: ResolveProofKey | None = None,
    resolve_proof_key_in_context: ResolveProofKeyInContext | None = None,
    trust_anchors: Sequence[Any] | None = None,
    require_key_attestation: bool = False,
    max_age_s: int = DEFAULT_PROOF_MAX_AGE_S,
    leeway_s: int = DEFAULT_LEEWAY_S,
    now: datetime | None = None,
    allowed_algs: frozenset[str] = ALLOWED_ALGS,
    batch_size: int | None = None,
) -> tuple[VerifiedProof, ...]:
    """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`).
    """
    if not isinstance(credential_issuer, str) or not credential_issuer:
        raise CredentialRequestMalformed("credential_issuer must be a non-empty string")
    if require_nonce and check_nonce is None:
        # Fail closed rather than verify a signature and skip the replay property.
        raise ClaimsInvalid(
            "a nonce is required but no check_nonce was given to consume it; pass "
            "check_nonce, or set require_nonce=False to opt out explicitly")
    if max_age_s < 0 or leeway_s < 0:
        raise CredentialRequestMalformed("max_age_s and leeway_s must not be negative")
    if resolve_proof_key is not None and resolve_proof_key_in_context is not None:
        # Two resolvers means a precedence between them, and a silent precedence among
        # key sources is the defect this verifier is built to refuse (see _KEY_PARAMS).
        raise CredentialRequestMalformed(
            "pass resolve_proof_key or resolve_proof_key_in_context, not both")

    if not isinstance(request, CredentialRequest):
        request = parse_credential_request(request, batch_size=batch_size)
    if request.proof_type != PROOF_TYPE_JWT:
        raise UnsupportedProofType(
            f"proof type {request.proof_type!r} is not supported (only 'jwt')")

    current = int(time.time()) if now is None else int(now.timestamp())
    verified = tuple(
        _verify_one_proof(
            value,
            credential_issuer=credential_issuer,
            expected_client_id=expected_client_id,
            resolve_proof_key=resolve_proof_key,
            resolve_proof_key_in_context=resolve_proof_key_in_context,
            trust_anchors=trust_anchors,
            require_key_attestation=require_key_attestation,
            max_age_s=max_age_s,
            leeway_s=leeway_s,
            current=current,
            now=now,
            allowed_algs=allowed_algs,
            index=index,
        )
        for index, value in enumerate(request.proofs)
    )

    _check_batch_invariants(verified, check_nonce=check_nonce, require_nonce=require_nonce)
    return verified