diff --git a/CHANGELOG.md b/CHANGELOG.md index 69698cf..0cf78c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,40 @@ All notable changes to **openvc** are documented here. The format follows ### Added +- **OpenID4VCI 1.0 wallet key-proof verification** + ([#141](https://github.com/luisgf/openvc/issues/141)). A new + `openvc.openid4vci` verifies the `openid4vci-proof+jwt` a wallet sends to a + Credential Endpoint (OID4VCI 1.0 App. F.1) and returns the public key it + demonstrated possession of — the value + `SdJwtVcProofSuite.issue(holder_jwk=…)` binds the credential to. + `parse_credential_request` pins the §8.2 wire contract; + `verify_credential_request_proofs` runs the checks in a fixed order, structure and + allow-lists **before** any crypto: the `typ` pin (so a KB-JWT, VP-JWT or + status-list token cannot be replayed as a key proof), the algorithm allow-list, + unknown `crit`, **exactly one** of the `jwk`/`kid`/`x5c`/`trust_chain` header key + parameters, the key↔`alg` (kty, crv) binding, the signature, `aud` pinned to the + Credential Issuer Identifier with a multi-valued `aud` rejected, and **`iat` + freshness in both directions** — stale *and* future-dated, which is new logic + because `check_jwt_temporal` reads `exp`/`nbf` and never `iat`. Across a batch: + one shared nonce, no two proofs on the same key. + + **Nonce state is the caller's, injected as a required callable** (`ConsumeNonce`) + rather than documented in prose — a plain `expected_nonce` string cannot express + "consume once, atomically", and would make the fail-open path the ergonomic one. + It is invoked exactly once per request and only *after* every signature has + verified, so an unauthenticated attacker cannot burn nonces. Replay surfaces as a + distinct `ProofReplayed` so an endpoint can answer `invalid_nonce` (fresh nonce, + retry) rather than rejecting the wallet. + + Stateless and transport-free by design + ([ADR-0007](https://github.com/luisgf/openvc/blob/main/docs/adr/ADR-0007-oid4vci-issuer-side.md)): + no endpoint, no Authorization Server, no state store, and no response/offer/metadata + builders — those belong to the issuing application. Key attestations are captured + **unverified**; `di_vp` and OpenID Federation `trust_chain` proof keys raise a typed + `UnsupportedProofType`. What this supports claiming is *OpenID4VCI 1.0 key-proof + verification* — not "issuance", and not HAIP, which additionally requires DPoP, key + attestations and client authentication. No new runtime dependency. + - **RFC 7638 JWK Thumbprint** ([#140](https://github.com/luisgf/openvc/issues/140)). `openvc.keys` grows `jwk_thumbprint` (base64url) and `jwk_thumbprint_bytes` (the raw digest), covering `EC`, `OKP`, `RSA` and `oct` keys. The canonical form is diff --git a/docs/api/openid4vci.md b/docs/api/openid4vci.md new file mode 100644 index 0000000..44ff47d --- /dev/null +++ b/docs/api/openid4vci.md @@ -0,0 +1,13 @@ +# 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](https://github.com/luisgf/openvc/blob/main/docs/adr/ADR-0007-oid4vci-issuer-side.md) +for the boundary and the [Issuing with OpenID4VCI](https://github.com/luisgf/openvc/wiki/Issuing-with-OpenID4VCI) +guide for the flow. + +::: openvc.openid4vci diff --git a/examples/12_oid4vci_key_proof.py b/examples/12_oid4vci_key_proof.py new file mode 100644 index 0000000..02cebb9 --- /dev/null +++ b/examples/12_oid4vci_key_proof.py @@ -0,0 +1,86 @@ +""" +12 — OpenID4VCI: verify a wallet's key proof, then issue bound to the key it proved. + +The wallet POSTs a Credential Request carrying an `openid4vci-proof+jwt`; the issuer +verifies it and gets back the wallet's public key, which is exactly what +`SdJwtVcProofSuite.issue` binds the credential to via `cnf`. + +openvc does the cryptography. The endpoint, the OAuth grant and the nonce store are +YOURS — here the store is a set, in production it must be atomic (see ADR-0007). + +Run: python examples/12_oid4vci_key_proof.py +""" +import time + +from _common import did_key_p256 + +from openvc import verify_credential +from openvc.keys import P256SigningKey +from openvc.openid4vci import verify_credential_request_proofs +from openvc.proof._jws import sign_compact +from openvc.proof.sd_jwt import SdJwtVcProofSuite + +CREDENTIAL_ISSUER = "https://issuer.example" +VCT = "https://credentials.example/university-degree" + +issuer, issuer_did = did_key_p256() +wallet = P256SigningKey.generate(kid="wallet-key-1") + +# --- the issuer's nonce store. Yours. Must be ATOMIC in production: a Redis SET NX or +# a SQL DELETE ... RETURNING — a read-then-write lets two concurrent requests both win. +issued_nonces = {"c_nonce_from_the_nonce_endpoint"} + + +def consume_nonce(nonce: str) -> bool: + """Mark the nonce used and report whether it was valid — in ONE step. + + `set.remove` either removes and returns, or raises: there is no window between + the check and the removal. Writing this as `if n in store: store.discard(n)` is + the bug that re-opens the replay window under concurrency. + """ + try: + issued_nonces.remove(nonce) + return True + except KeyError: + return False + + +# --- wallet side: mint the key proof (App. F.1) ------------------------------------ +key_proof = sign_compact( + {"typ": "openid4vci-proof+jwt", "alg": wallet.alg, "jwk": wallet.public_jwk()}, + {"aud": CREDENTIAL_ISSUER, # this issuer, and only this one + "iat": int(time.time()), # freshness, checked both ways + "nonce": "c_nonce_from_the_nonce_endpoint"}, + signing_key=wallet, +) +credential_request = { + "credential_configuration_id": "UniversityDegree", + "proofs": {"jwt": [key_proof]}, +} + +# --- issuer side: verify, then issue ----------------------------------------------- +proof, = verify_credential_request_proofs( + credential_request, + credential_issuer=CREDENTIAL_ISSUER, + check_nonce=consume_nonce, # state: injected, never stored here +) +print("proof verified — key source:", proof.key_source, "| thumbprint:", proof.thumbprint) + +sd_jwt = SdJwtVcProofSuite().issue( + {"iss": issuer_did, "degree": "BSc Computer Science"}, + signing_key=issuer, vct=VCT, disclosable=["degree"], + holder_jwk=proof.public_jwk, # <- the key the proof demonstrated +) + +# --- the credential verifies, and is bound to the wallet key ----------------------- +result = verify_credential(sd_jwt) +assert result.raw.confirmation == {"jwk": wallet.public_jwk()} +print("issued and bound to the wallet key:", result.format) + +# --- replay: the nonce is single-use, so the same proof cannot be redeemed twice ---- +try: + verify_credential_request_proofs( + credential_request, credential_issuer=CREDENTIAL_ISSUER, + check_nonce=consume_nonce) +except Exception as exc: + print("replay rejected:", type(exc).__name__) diff --git a/examples/README.md b/examples/README.md index 9ef11df..a56f009 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,7 @@ python examples/01_verify_pipeline.py | `09_haip_encrypted_response.py` | HAIP `direct_post.jwt`: the wallet returns the `vp_token` inside a JWE (`ECDH-ES`/AES-GCM); the verifier decrypts with its `KeyAgreementKey` and verifies in one call (`verify_encrypted_vp_response`) | | `10_sd_jwt_type_metadata.py` | SD-JWT VC Type Metadata: an issuer pins the type with `vct#integrity`; the verifier resolves the metadata, checks integrity + `vct`, and validates the claims against the type's `claims` metadata | | `11_spanish_university_credential.py` | a Spanish university diploma end to end: the issuer's document-signer chains to an **FNMT** anchor (the Spanish trusted list / `x5c`), and the diploma is an **SD-JWT VC** verified with that FNMT-anchored key + holder binding — trust + credential, offline ([walkthrough](https://github.com/luisgf/openvc/wiki/Spanish-University-Credential)) | +| `12_oid4vci_key_proof.py` | OpenID4VCI: a wallet mints an `openid4vci-proof+jwt`, the issuer verifies it (`typ` pin, `aud`, `iat` freshness both ways, single-use nonce) and issues an SD-JWT VC bound via `cnf` to the key the proof demonstrated — then the same proof is replayed and rejected ([guide](https://github.com/luisgf/openvc/wiki/Issuing-with-OpenID4VCI)) | `_common.py` holds the shared `did_key_ed25519()` / `did_key_p256()` helpers that mint a signing key already keyed to its `did:key` verification method. diff --git a/mkdocs.yml b/mkdocs.yml index 951b256..3bfe4ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,6 +47,7 @@ nav: - Verification pipeline: api/verification.md - Async verification: api/async.md - OpenID4VP presentations: api/openid4vp.md + - OpenID4VCI key proofs: api/openid4vci.md - ISO mdoc (mso_mdoc): api/mdoc.md - Proof suites: api/proofs.md - DIDs & keys: api/dids-keys.md diff --git a/src/openvc/openid4vci.py b/src/openvc/openid4vci.py new file mode 100644 index 0000000..6ac12ba --- /dev/null +++ b/src/openvc/openid4vci.py @@ -0,0 +1,614 @@ +""" +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. + +Scope: the ``jwt`` proof type only. ``attestation`` key attestations are captured +verbatim and **unverified** (their trust model needs a wallet-provider anchor class of +its own); ``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 +attestations and client authentication, all of them downstream. +""" +from __future__ import annotations + +import json +import math +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, Mapping, Sequence + +from .errors import OpenvcError +from .keys import MLDSA_ALGS, jwk_thumbprint +from .proof._jws import parse_compact, verify_compact +from .proof._verify_common import DEFAULT_LEEWAY_S, check_jwt_temporal, reject_unknown_crit +from .proof.errors import ClaimsInvalid, UnsupportedAlgorithm +from .proof.vc_jwt import ALLOWED_ALGS + +__all__ = [ + "verify_credential_request_proofs", + "parse_credential_request", + "CredentialRequest", + "VerifiedProof", + "ConsumeNonce", + "ResolveProofKey", + "OpenID4VCIError", + "CredentialRequestMalformed", + "UnsupportedProofType", + "ProofReplayed", + "PROOF_TYPE_JWT", + "PROOF_TYP", + "DEFAULT_PROOF_MAX_AGE_S", + "MAX_PROOF_BYTES", +] + +# OID4VCI 1.0 App. F: the only proof type this module verifies. +PROOF_TYPE_JWT = "jwt" + +# App. F.1 pins `typ` to "openid4vci-proof+jwt". RFC 7515 §4.1.9 makes omitting the +# "application/" prefix a *media-type equivalence*, not an algorithm widening, so both +# spellings are accepted — the same rule as the SD-JWT issuer `typ`. +PROOF_TYP = frozenset({"openid4vci-proof+jwt", "application/openid4vci-proof+jwt"}) + +# How old a proof's `iat` may be. A key proof is a freshness artifact: the wallet mints +# one per request, so minutes are generous. +DEFAULT_PROOF_MAX_AGE_S = 300 + +# A proof value is a compact JWS carrying a public key, not a document. Cap it before +# any parsing so a hostile request cannot make us allocate — the `jwe.MAX_JWE_BYTES` +# pattern. +MAX_PROOF_BYTES = 16 * 1024 + +# The header key parameters App. F.1 defines. Exactly one must be present: two lets an +# attacker pair a `kid` naming an honest key with a `jwk` they control, and any +# implementation that "prefers" one silently accepts. +_KEY_PARAMS = ("jwk", "kid", "x5c", "trust_chain") + +# JOSE alg -> the (kty, crv) a key must have to be used with it. Binding these before +# the signature check is what stops an `alg: ES256` header pointing at an Ed25519 JWK, +# where the outcome would otherwise depend on the backend's own validation. +_ALG_KEY_BINDING = { + "EdDSA": ("OKP", "Ed25519"), + "Ed25519": ("OKP", "Ed25519"), # RFC 9864 fully-specified name + "ES256": ("EC", "P-256"), + "ES384": ("EC", "P-384"), +} + +# Members that must never appear in a proof's `jwk`: their presence means a wallet is +# leaking a private key, or is probing for a backend that would use it. +_PRIVATE_JWK_MEMBERS = frozenset({"d", "k", "p", "q", "dp", "dq", "qi", "priv"}) + + +# --------------------------------------------------------------------------- # +# Errors +# --------------------------------------------------------------------------- # + +class OpenID4VCIError(OpenvcError): + """Base class for OpenID4VCI issuer-side failures.""" + + +class CredentialRequestMalformed(OpenID4VCIError): + """The Credential Request shape is invalid (not the §8.2 wire contract).""" + + +class UnsupportedProofType(OpenID4VCIError): + """A proof type or key parameter this verifier does not implement.""" + + +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. + """ + + +# --------------------------------------------------------------------------- # +# Injected state and I/O (openvc stores none of it) +# --------------------------------------------------------------------------- # + +ConsumeNonce = Callable[[str], bool] +"""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] +"""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. +""" + + +# --------------------------------------------------------------------------- # +# Results +# --------------------------------------------------------------------------- # + +@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. + """ + 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) + + +@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) + + +# --------------------------------------------------------------------------- # +# Request shape (OID4VCI 1.0 §8.2) +# --------------------------------------------------------------------------- # + +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, + ) + + +def _parse_proofs(proofs: Any, limit: int) -> tuple[str, tuple[str, ...]]: + """The ``proofs`` object: exactly one member, a non-empty array of strings. + + OID4VCI 1.0 §8.2 removed the singular ``proof`` parameter; ``proofs`` is an object + keyed by proof type. Allowing more than one member would let a wallet offer a type + we verify alongside one we do not, and leave the choice to us. + """ + if not isinstance(proofs, Mapping): + raise CredentialRequestMalformed("Credential Request needs a proofs object") + if len(proofs) != 1: + raise CredentialRequestMalformed( + f"proofs must carry exactly one proof type, got {len(proofs)}") + proof_type = next(iter(proofs)) + values = proofs[proof_type] + if not isinstance(proof_type, str) or not proof_type: + raise CredentialRequestMalformed("proofs key must be a non-empty string") + if not isinstance(values, (list, tuple)) or not values: + raise CredentialRequestMalformed( + f"proofs[{proof_type!r}] must be a non-empty array") + if len(values) > limit: + raise CredentialRequestMalformed( + f"proofs[{proof_type!r}] has {len(values)} entries, batch limit is {limit}") + for value in values: + if not isinstance(value, str) or not value: + raise CredentialRequestMalformed( + f"proofs[{proof_type!r}] entries must be non-empty strings") + if len(value.encode("utf-8")) > MAX_PROOF_BYTES: + raise CredentialRequestMalformed( + f"proofs[{proof_type!r}] entry exceeds {MAX_PROOF_BYTES} bytes") + return proof_type, tuple(values) + + +def _as_mapping(value: Mapping[str, Any] | str, subject: str) -> Mapping[str, Any]: + """Accept a parsed object or a JSON string, fail closed on anything else.""" + if isinstance(value, Mapping): + return value + if not isinstance(value, str): + raise CredentialRequestMalformed(f"{subject} must be an object or a JSON string") + try: + parsed = json.loads(value) + except (ValueError, RecursionError) as exc: # RecursionError: hostile nesting + raise CredentialRequestMalformed(f"{subject} is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise CredentialRequestMalformed(f"{subject} must be a JSON object") + return parsed + + +# --------------------------------------------------------------------------- # +# Key-proof verification (OID4VCI 1.0 App. F.1) +# --------------------------------------------------------------------------- # + +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, + trust_anchors: Sequence[Any] | None = None, + 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* and + *trust_anchors* enable the ``kid`` and ``x5c`` key parameters respectively; without + them, proofs using those parameters are rejected. *expected_client_id*, when given, + pins the proof's ``iss``. *now* pins the instant for deterministic tests. + + **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 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, + trust_anchors=trust_anchors, + max_age_s=max_age_s, + leeway_s=leeway_s, + current=current, + now=now, + allowed_algs=allowed_algs, + ) + for value in request.proofs + ) + + _check_batch_invariants(verified, check_nonce=check_nonce, require_nonce=require_nonce) + return verified + + +def _check_batch_invariants( + verified: tuple[VerifiedProof, ...], *, + check_nonce: ConsumeNonce | None, + require_nonce: bool, +) -> None: + """The invariants that only exist across the whole request. + + Deliberately not reachable per-proof: consuming the nonce inside the loop would + fail the second proof of a batch, and a caller looping over a public singular + verifier would reintroduce exactly that. Hence no public singular verifier. + """ + nonces = {proof.nonce for proof in verified} + if len(nonces) != 1: + raise ClaimsInvalid( + "all key proofs in a Credential Request must carry the same nonce") + nonce = nonces.pop() + if require_nonce and nonce is None: + raise ClaimsInvalid("key proof is missing the required nonce") + + thumbprints = [proof.thumbprint for proof in verified] + if len(set(thumbprints)) != len(thumbprints): + # N credentials must mean N keys; otherwise a wallet gets N copies bound to one. + raise ClaimsInvalid("two key proofs in the batch are bound to the same key") + + # Last, and exactly once: every signature above has verified, so an unauthenticated + # attacker cannot reach this and burn a nonce. + if nonce is not None and check_nonce is not None: + if not check_nonce(nonce): + raise ProofReplayed(f"nonce {nonce!r} was already consumed or is unknown") + + +def _verify_one_proof( + proof: str, *, + credential_issuer: str, + expected_client_id: str | None, + resolve_proof_key: ResolveProofKey | None, + trust_anchors: Sequence[Any] | None, + max_age_s: int, + leeway_s: int, + current: int, + now: datetime | None, + allowed_algs: frozenset[str], +) -> VerifiedProof: + """One ``openid4vci-proof+jwt``, structure and allow-lists before any crypto.""" + header, _, _, _ = parse_compact(proof) + + typ = header.get("typ") + if typ not in PROOF_TYP: + # Type confusion is the attack: without this pin a KB-JWT, a VP-JWT, an ID + # token or a status-list token could be replayed as a key proof. + raise ClaimsInvalid(f"key proof typ must be openid4vci-proof+jwt, got {typ!r}") + + alg = header.get("alg") + if not isinstance(alg, str) or alg not in allowed_algs: + raise UnsupportedAlgorithm(f"key proof alg {alg!r} is not allow-listed") + reject_unknown_crit(header) + + public_jwk, key_source = _proof_key( + header, alg=alg, resolve_proof_key=resolve_proof_key, + trust_anchors=trust_anchors, now=now) + + # Re-parses and re-checks alg/crit; that redundancy is deliberate — one audited + # entry point for every signature in the library. + _, claims = verify_compact(proof, public_key_jwk=public_jwk, allowed_algs=allowed_algs) + + _check_audience(claims.get("aud"), credential_issuer) + issued_at = _check_freshness( + claims.get("iat"), max_age_s=max_age_s, leeway_s=leeway_s, current=current) + check_jwt_temporal(claims, leeway_s=leeway_s, subject="key proof", now=current) + + client_id = claims.get("iss") + if client_id is not None and not isinstance(client_id, str): + raise ClaimsInvalid("key proof iss must be a string when present") + if expected_client_id is not None and client_id != expected_client_id: + raise ClaimsInvalid( + f"key proof iss {client_id!r} does not match the authenticated client") + + nonce = claims.get("nonce") + if nonce is not None and (not isinstance(nonce, str) or not nonce): + raise ClaimsInvalid("key proof nonce must be a non-empty string when present") + + attestation = header.get("key_attestation") + if attestation is not None and not isinstance(attestation, str): + raise ClaimsInvalid("key_attestation header must be a string when present") + + return VerifiedProof( + public_jwk=public_jwk, + thumbprint=jwk_thumbprint(public_jwk), + alg=alg, + key_source=key_source, + issued_at=issued_at, + nonce=nonce, + client_id=client_id, + key_attestation=attestation, # UNVERIFIED — peek doctrine + header=header, + claims=claims, + ) + + +def _proof_key( + header: Mapping[str, Any], *, + alg: str, + resolve_proof_key: ResolveProofKey | None, + trust_anchors: Sequence[Any] | None, + now: datetime | None, +) -> tuple[dict[str, Any], str]: + """The wallet's public key, from **exactly one** header key parameter.""" + present = [name for name in _KEY_PARAMS if header.get(name) is not None] + if len(present) != 1: + # Zero means there is no key. Two lets an attacker pair a `kid` naming an + # honest key with a `jwk` they control, and be accepted by any implementation + # that silently prefers one of them. + raise ClaimsInvalid( + f"key proof must carry exactly one of {'/'.join(_KEY_PARAMS)}, got {present}") + source = present[0] + + if source == "jwk": + jwk = header["jwk"] + if not isinstance(jwk, Mapping): + raise ClaimsInvalid("key proof jwk header must be an object") + leaked = sorted(_PRIVATE_JWK_MEMBERS.intersection(jwk)) + if leaked: + raise ClaimsInvalid(f"key proof jwk carries private members {leaked}") + jwk = dict(jwk) + _check_key_binds_to_alg(jwk, alg) + return jwk, source + + if source == "x5c": + if not trust_anchors: + # An unanchored chain is decoration, not trust. + raise ClaimsInvalid( + "key proof uses x5c but no trust_anchors were given to validate it") + from . import x5c as _x5c + chain = _x5c.load_x5c_chain(header["x5c"]) + # Deliberately not resolve_x5c_key: its iss->SAN binding and P-256-only leaf + # rule are issuer-certificate rules; a wallet key certificate binds differently. + # `now` is threaded through so a caller that pins the instant pins the chain's + # validity window too — otherwise a frozen-clock verification would silently + # path-validate against the real wall clock. + _x5c.validate_cert_chain( + chain[0], chain[1:], trust_anchors=trust_anchors, now=now) + jwk = _x5c.leaf_public_jwk(chain[0]) + _check_key_binds_to_alg(jwk, alg) + return jwk, source + + if source == "kid": + kid = header["kid"] + if not isinstance(kid, str) or not kid: + raise ClaimsInvalid("key proof kid header must be a non-empty string") + if resolve_proof_key is None: + raise ClaimsInvalid( + "key proof uses kid but no resolve_proof_key was given to resolve it") + jwk = resolve_proof_key(kid) + if not isinstance(jwk, Mapping): + raise ClaimsInvalid(f"resolve_proof_key({kid!r}) did not return a JWK") + jwk = dict(jwk) + _check_key_binds_to_alg(jwk, alg) + return jwk, source + + raise UnsupportedProofType( + "OpenID Federation trust_chain proof keys are not supported") + + +def _check_key_binds_to_alg(jwk: Mapping[str, Any], alg: str) -> None: + """The key's type must match the header ``alg``, before the signature is checked. + + Otherwise an ``alg: ES256`` header pointing at an Ed25519 JWK reaches the backend + and the outcome depends on that backend's own validation rather than on us. + """ + binding = _ALG_KEY_BINDING.get(alg) + if binding is not None: + kty, crv = binding + if jwk.get("kty") != kty or jwk.get("crv") != crv: + raise ClaimsInvalid( + f"key proof alg {alg} needs a kty={kty} crv={crv} key, got " + f"kty={jwk.get('kty')!r} crv={jwk.get('crv')!r}") + return + if alg in MLDSA_ALGS: # only reachable via a widened allowed_algs + if jwk.get("kty") != "AKP" or jwk.get("alg") != alg: + raise ClaimsInvalid(f"key proof alg {alg} needs a matching AKP key") + return + raise UnsupportedAlgorithm(f"key proof alg {alg!r} has no key binding rule") + + +def _check_audience(aud: Any, credential_issuer: str) -> None: + """``aud`` must be the Credential Issuer Identifier, and nothing else. + + A single-element array is accepted as the JWT spelling of one audience. A + multi-valued ``aud`` is **rejected** — deliberately stricter than RFC 7519, because + a proof audienced at several issuers is a cross-issuer replay vector by + construction. + """ + if isinstance(aud, str): + values = [aud] + elif isinstance(aud, (list, tuple)): + values = list(aud) + else: + raise ClaimsInvalid("key proof aud must be a string or an array") + if len(values) != 1: + raise ClaimsInvalid( + f"key proof aud must name exactly one audience, got {len(values)}") + if values[0] != credential_issuer: + raise ClaimsInvalid( + f"key proof aud {values[0]!r} is not this Credential Issuer " + f"{credential_issuer!r}") + + +def _check_freshness(iat: Any, *, max_age_s: int, leeway_s: int, current: int) -> int: + """``iat`` freshness, in **both** directions. + + ``check_jwt_temporal`` covers ``exp``/``nbf`` and never looks at ``iat``, but a key + proof's whole purpose is freshness, and wallets are not required to set ``exp``. + + The future-dated direction is the one implementations forget: without it a wallet + signs once with ``iat = now + 10y`` and holds a proof that never goes stale. The + non-finite guard matters for the same reason it does in ``check_jwt_temporal`` — + ``json.loads`` accepts ``NaN``, and every comparison against it is ``False``, i.e. + *never stale*. + """ + if iat is None: + raise ClaimsInvalid("key proof is missing the required iat") + if isinstance(iat, bool) or not isinstance(iat, (int, float)) or not math.isfinite(iat): + raise ClaimsInvalid("key proof iat must be a finite numeric timestamp") + if current - iat > max_age_s + leeway_s: + raise ClaimsInvalid("key proof is too old") + if iat - current > leeway_s: + raise ClaimsInvalid("key proof iat is in the future") + return int(iat) diff --git a/tests/test_error_taxonomy.py b/tests/test_error_taxonomy.py index 7e06abc..7f9b5c7 100644 --- a/tests/test_error_taxonomy.py +++ b/tests/test_error_taxonomy.py @@ -49,6 +49,22 @@ def test_except_signature_invalid_catches_every_suite(): assert di is sd is jose is proof_errors.SignatureInvalid +def test_openid4vci_reuses_the_shared_proof_leaves(): + """The OID4VCI verifier defines one area root and three leaves that earn their + own class; everything else — bad aud, stale iat, wrong alg, broken JWS — surfaces + as the SHARED proof leaves, so `except ClaimsInvalid` works across the library.""" + from openvc import openid4vci + + assert issubclass(openid4vci.OpenID4VCIError, OpenvcError) + for leaf in (openid4vci.CredentialRequestMalformed, + openid4vci.UnsupportedProofType, + openid4vci.ProofReplayed): + assert issubclass(leaf, openid4vci.OpenID4VCIError) + # ProofReplayed must NOT be a ClaimsInvalid: a Credential Endpoint distinguishes + # "here is a fresh nonce, retry" from "your proof is wrong". + assert not issubclass(openid4vci.ProofReplayed, proof_errors.ClaimsInvalid) + + def test_deprecated_codec_aliases_warn_and_resolve(): import pytest from openvc.proof import ecdsa_sd as m diff --git a/tests/test_openid4vci.py b/tests/test_openid4vci.py new file mode 100644 index 0000000..4b164b8 --- /dev/null +++ b/tests/test_openid4vci.py @@ -0,0 +1,669 @@ +""" +tests/test_openid4vci.py — OpenID4VCI 1.0 key-proof verification (issue #141). + +Pins the App. F.1 contract and, above all, the adversarial corpus ADR-0007 D5 names: + + * the `typ` pin — a KB-JWT / VP-JWT / status-list token must not be replayable as a + key proof; + * the alg allow-list *before* any crypto, and the (kty, crv) binding between the + header alg and the key it points at; + * **exactly one** of jwk/kid/x5c/trust_chain — two present is a key-substitution + vector, zero means there is no key; + * `aud` bound to this Credential Issuer, multi-valued rejected; + * `iat` freshness in BOTH directions, including the NaN fail-open trap; + * the batch invariants that only exist in the plural — one shared nonce, consumed + exactly once, no two proofs on the same key; + * nonce replay surfacing as a distinct ProofReplayed. + +Proofs are minted locally with offline keys; wire shapes follow OID4VCI 1.0 §8.2. +""" + +import base64 +import datetime as dt + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +from openvc.keys import Ed25519SigningKey, P256SigningKey, P384SigningKey, jwk_thumbprint +from openvc.openid4vci import ( + CredentialRequest, + CredentialRequestMalformed, + ProofReplayed, + UnsupportedProofType, + parse_credential_request, + verify_credential_request_proofs, +) +from openvc.proof._jws import sign_compact +from openvc.proof.errors import ClaimsInvalid, MalformedToken, UnsupportedAlgorithm + +ISSUER = "https://issuer.example" +NONCE = "c-nonce-abc123" +NOW = dt.datetime(2026, 7, 24, 12, 0, tzinfo=dt.timezone.utc) +NOW_TS = int(NOW.timestamp()) + + +# --------------------------------------------------------------------------- helpers # + +_UNSET = object() # distinguishes "not given" from an explicit None/"" under test + + +def _proof( + key, *, aud=ISSUER, nonce=NONCE, iat=NOW_TS, typ="openid4vci-proof+jwt", + alg=_UNSET, key_param="jwk", jwk=None, header_extra=None, claims_extra=None, +): + header = {"typ": typ, "alg": key.alg if alg is _UNSET else alg} + if key_param == "jwk": + header["jwk"] = key.public_jwk() if jwk is None else jwk + elif key_param == "kid": + header["kid"] = "wallet-key-1" + elif key_param is not None: + header[key_param] = jwk + header.update(header_extra or {}) + claims = {"aud": aud} + if iat is not None: + claims["iat"] = iat + if nonce is not None: + claims["nonce"] = nonce + claims.update(claims_extra or {}) + return sign_compact(header, claims, signing_key=key) + + +def _request(*proofs, config_id="UniversityDegree", proof_type="jwt"): + return {"credential_configuration_id": config_id, "proofs": {proof_type: list(proofs)}} + + +def _verify(body, **kw): + kw.setdefault("credential_issuer", ISSUER) + kw.setdefault("check_nonce", lambda n: True) + kw.setdefault("now", NOW) + return verify_credential_request_proofs(body, **kw) + + +def _cert(subject, issuer, issuer_key, *, ca, subject_key=None, curve=ec.SECP256R1()): + subject_key = subject_key or ec.generate_private_key(curve) + builder = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])) + .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)])) + .public_key(subject_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(NOW - dt.timedelta(days=365)) + .not_valid_after(NOW + dt.timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True)) + if ca: + builder = builder.add_extension( + x509.KeyUsage( + digital_signature=False, content_commitment=False, key_encipherment=False, + data_encipherment=False, key_agreement=False, key_cert_sign=True, + crl_sign=True, encipher_only=False, decipher_only=False), + critical=True) + return builder.sign(issuer_key, hashes.SHA256()), subject_key + + +def _wallet_chain(): + """(x5c list [leaf, inter], root cert, a SigningKey holding the leaf key).""" + root_key = ec.generate_private_key(ec.SECP256R1()) + root, _ = _cert("root", "root", root_key, ca=True, subject_key=root_key) + inter, inter_key = _cert("inter", "root", root_key, ca=True) + leaf, leaf_key = _cert("wallet", "inter", inter_key, ca=False) + der = [base64.b64encode(c.public_bytes(serialization.Encoding.DER)).decode("ascii") + for c in (leaf, inter)] + return der, root, P256SigningKey(leaf_key, kid="wallet") + + +# ------------------------------------------------------------------------ happy path # + +@pytest.mark.parametrize("factory", [ + lambda: P256SigningKey.generate(kid="w"), + lambda: P384SigningKey.generate(kid="w"), + lambda: Ed25519SigningKey.generate(kid="w"), + lambda: Ed25519SigningKey.generate(kid="w", alg="Ed25519"), +]) +def test_a_valid_proof_returns_the_wallet_key(factory): + key = factory() + proofs = _verify(_request(_proof(key))) + assert len(proofs) == 1 + assert proofs[0].public_jwk == key.public_jwk() + assert proofs[0].thumbprint == jwk_thumbprint(key.public_jwk()) + assert proofs[0].alg == key.alg + assert proofs[0].key_source == "jwk" + assert proofs[0].nonce == NONCE + assert proofs[0].issued_at == NOW_TS + + +def test_the_returned_key_is_what_sd_jwt_issue_binds_to(): + """The whole point: VerifiedProof.public_jwk feeds straight into issue().""" + from openvc.proof.sd_jwt import SdJwtVcProofSuite + + wallet = P256SigningKey.generate(kid="w") + issuer = Ed25519SigningKey.generate(kid=f"{ISSUER}#key-1") + proof, = _verify(_request(_proof(wallet))) + + sd_jwt = SdJwtVcProofSuite().issue( + {"iss": ISSUER, "given_name": "Ada"}, signing_key=issuer, + holder_jwk=proof.public_jwk, vct="https://credentials.example/id") + result = SdJwtVcProofSuite().verify(sd_jwt, public_key_jwk=issuer.public_jwk()) + assert result.confirmation == {"jwk": wallet.public_jwk()} + + +def test_both_media_type_spellings_of_typ_are_accepted(): + key = P256SigningKey.generate(kid="w") + assert _verify(_request(_proof(key, typ="application/openid4vci-proof+jwt"))) + + +def test_accepts_a_credential_identifier_instead_of_a_configuration_id(): + key = P256SigningKey.generate(kid="w") + body = {"credential_identifier": "degree-1", "proofs": {"jwt": [_proof(key)]}} + assert _verify(body) + + +def test_a_prevalidated_request_object_can_be_passed_through(): + key = P256SigningKey.generate(kid="w") + request = parse_credential_request(_request(_proof(key))) + assert isinstance(request, CredentialRequest) + assert _verify(request) + + +# ---------------------------------------------------------------------- the typ pin # + +@pytest.mark.parametrize("typ", [ + "kb+jwt", # an SD-JWT Key Binding JWT + "JWT", # a plain JWT / ID token + "statuslist+jwt", # a status-list token + "dc+sd-jwt", # an SD-JWT VC issuer JWT + "openid4vci-proof+JWT", # case games + "", + None, +]) +def test_a_foreign_typ_is_not_replayable_as_a_key_proof(typ): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="typ must be openid4vci-proof"): + _verify(_request(_proof(key, typ=typ))) + + +# ------------------------------------------------------------------- the allow-list # + +@pytest.mark.parametrize("alg", ["none", "HS256", "RS256", "PS256", "ES512", "", None]) +def test_a_non_allow_listed_alg_is_rejected_before_any_crypto(alg): + key = P256SigningKey.generate(kid="w") + with pytest.raises(UnsupportedAlgorithm): + _verify(_request(_proof(key, alg=alg))) + + +def test_the_allow_list_can_be_narrowed_by_the_caller(): + key = Ed25519SigningKey.generate(kid="w") + with pytest.raises(UnsupportedAlgorithm): + _verify(_request(_proof(key)), allowed_algs=frozenset({"ES256"})) + + +def test_unknown_crit_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(MalformedToken): + _verify(_request(_proof(key, header_extra={"crit": ["exp"]}))) + + +def test_header_alg_must_bind_to_the_key_it_names(): + """`alg: ES256` over an Ed25519 JWK must die here, not inside the backend.""" + signer = Ed25519SigningKey.generate(kid="w") + proof = _proof(signer, alg="EdDSA", jwk=signer.public_jwk()) + # Forge the header alg while keeping the Ed25519 key. + header, payload, sig = proof.split(".") + forged = base64.urlsafe_b64encode( + b'{"typ":"openid4vci-proof+jwt","alg":"ES256","jwk":' + + base64.urlsafe_b64decode(header + "==").split(b'"jwk":')[1] + ).rstrip(b"=").decode() + with pytest.raises(ClaimsInvalid, match="needs a kty"): + _verify(_request(f"{forged}.{payload}.{sig}")) + + +@pytest.mark.parametrize("bad_jwk", [ + {"kty": "EC", "crv": "P-384", "x": "AA", "y": "BB"}, # right kty, wrong curve + {"kty": "OKP", "crv": "Ed25519", "x": "AA"}, # wrong kty entirely + {"kty": "RSA", "n": "AA", "e": "AQAB"}, + {"kty": "EC", "x": "AA", "y": "BB"}, # no crv at all + {}, +]) +def test_es256_over_a_mismatched_key_is_rejected(bad_jwk): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="needs a kty"): + _verify(_request(_proof(key, jwk=bad_jwk))) + + +# ------------------------------------------------------------ the key parameter set # + +def test_two_key_parameters_are_rejected(): + """A `kid` naming an honest key paired with an attacker's `jwk`.""" + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="exactly one of"): + _verify(_request(_proof(key, header_extra={"kid": "honest-key"}))) + + +def test_no_key_parameter_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="exactly one of"): + _verify(_request(_proof(key, key_param=None))) + + +def test_trust_chain_is_typed_not_silently_ignored(): + key = P256SigningKey.generate(kid="w") + proof = _proof(key, key_param="trust_chain", jwk=["chain"]) + with pytest.raises(UnsupportedProofType, match="trust_chain"): + _verify(_request(proof)) + + +@pytest.mark.parametrize("member", ["d", "k", "p", "q", "dp", "dq", "qi"]) +def test_a_jwk_carrying_private_members_is_rejected(member): + key = P256SigningKey.generate(kid="w") + leaky = dict(key.public_jwk(), **{member: "c2VjcmV0"}) + with pytest.raises(ClaimsInvalid, match="private members"): + _verify(_request(_proof(key, jwk=leaky))) + + +def test_a_non_object_jwk_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="must be an object"): + _verify(_request(_proof(key, jwk="not-an-object"))) + + +# ---------------------------------------------------------------------------- x5c # + +def test_an_anchored_x5c_chain_resolves_the_wallet_key(): + x5c, root, leaf_key = _wallet_chain() + proof = _proof(leaf_key, key_param="x5c", jwk=x5c) + verified, = _verify(_request(proof), trust_anchors=[root]) + assert verified.key_source == "x5c" + assert verified.public_jwk["crv"] == "P-256" + + +def test_x5c_without_trust_anchors_is_rejected(): + """An unanchored chain is decoration, not trust.""" + x5c, _, leaf_key = _wallet_chain() + proof = _proof(leaf_key, key_param="x5c", jwk=x5c) + with pytest.raises(ClaimsInvalid, match="no trust_anchors"): + _verify(_request(proof)) + + +def test_an_expired_wallet_certificate_is_rejected_against_the_pinned_clock(): + """`now` must reach the chain's validity window, not just the proof's iat — + otherwise a frozen-clock verification path-validates against the wall clock.""" + from openvc.x5c import X5cError + + x5c, root, leaf_key = _wallet_chain() # valid NOW-365d .. NOW+365d + later = NOW + dt.timedelta(days=400) + proof = _proof(leaf_key, key_param="x5c", jwk=x5c, iat=int(later.timestamp())) + with pytest.raises(X5cError): + _verify(_request(proof), trust_anchors=[root], now=later) + + +def test_an_x5c_chain_to_a_foreign_root_is_rejected(): + from openvc.x5c import X5cError + + x5c, _, leaf_key = _wallet_chain() + _, other_root, _ = _wallet_chain() + proof = _proof(leaf_key, key_param="x5c", jwk=x5c) + with pytest.raises(X5cError): + _verify(_request(proof), trust_anchors=[other_root]) + + +# ---------------------------------------------------------------------------- kid # + +def test_kid_resolves_through_the_injected_callable(): + key = P256SigningKey.generate(kid="w") + verified, = _verify( + _request(_proof(key, key_param="kid")), + resolve_proof_key=lambda kid: key.public_jwk()) + assert verified.key_source == "kid" + + +def test_kid_without_a_resolver_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="no resolve_proof_key"): + _verify(_request(_proof(key, key_param="kid"))) + + +def test_a_resolver_returning_a_non_jwk_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="did not return a JWK"): + _verify(_request(_proof(key, key_param="kid")), + resolve_proof_key=lambda kid: "nope") + + +def test_a_resolver_returning_the_wrong_key_fails_the_signature(): + from openvc.proof.errors import SignatureInvalid + + key = P256SigningKey.generate(kid="w") + other = P256SigningKey.generate(kid="other") + with pytest.raises(SignatureInvalid): + _verify(_request(_proof(key, key_param="kid")), + resolve_proof_key=lambda kid: other.public_jwk()) + + +# ---------------------------------------------------------------------- signature # + +def test_a_tampered_payload_fails_the_signature(): + from openvc.proof.errors import SignatureInvalid + + key = P256SigningKey.generate(kid="w") + header, _, sig = _proof(key).split(".") + forged = base64.urlsafe_b64encode( + b'{"aud":"%s","iat":%d,"nonce":"%s"}' % (ISSUER.encode(), NOW_TS, b"other") + ).rstrip(b"=").decode() + with pytest.raises(SignatureInvalid): + _verify(_request(f"{header}.{forged}.{sig}")) + + +@pytest.mark.parametrize("junk", ["a.b", "a.b.c.d", "not-a-jws", "..", "a.b.!!"]) +def test_structurally_broken_proofs_are_typed(junk): + """An empty string is not here: the shape validator rejects it before parsing.""" + with pytest.raises(MalformedToken): + _verify(_request(junk)) + + +# --------------------------------------------------------------------------- aud # + +def test_a_proof_for_another_issuer_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="is not this Credential Issuer"): + _verify(_request(_proof(key, aud="https://other-issuer.example"))) + + +def test_a_single_element_aud_array_is_accepted(): + key = P256SigningKey.generate(kid="w") + assert _verify(_request(_proof(key, aud=[ISSUER]))) + + +def test_a_multi_valued_aud_is_rejected_as_a_cross_issuer_replay_vector(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="exactly one audience"): + _verify(_request(_proof(key, aud=[ISSUER, "https://other.example"]))) + + +@pytest.mark.parametrize("aud", [None, 42, {"a": 1}, []]) +def test_a_missing_or_malformed_aud_is_rejected(aud): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid): + _verify(_request(_proof(key, aud=aud))) + + +# ------------------------------------------------------------------ iat freshness # + +def test_a_stale_proof_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="too old"): + _verify(_request(_proof(key, iat=NOW_TS - 3600))) + + +def test_a_future_dated_proof_is_rejected(): + """Without this a wallet signs once with iat=now+10y and never goes stale.""" + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="in the future"): + _verify(_request(_proof(key, iat=NOW_TS + 10 * 365 * 24 * 3600))) + + +def test_a_missing_iat_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="missing the required iat"): + _verify(_request(_proof(key, iat=None))) + + +@pytest.mark.parametrize("iat", [float("nan"), float("inf"), float("-inf")]) +def test_a_non_finite_iat_cannot_make_a_proof_never_stale(iat): + """json.loads accepts NaN, and every comparison against it is False.""" + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="finite numeric"): + _verify(_request(_proof(key, iat=iat))) + + +@pytest.mark.parametrize("iat", [True, "1753358400", None]) +def test_a_non_numeric_iat_is_rejected(iat): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid): + _verify(_request(_proof(key, iat=iat))) + + +def test_leeway_absorbs_modest_clock_skew_in_both_directions(): + key = P256SigningKey.generate(kid="w") + assert _verify(_request(_proof(key, iat=NOW_TS + 30)), leeway_s=60) + assert _verify(_request(_proof(key, iat=NOW_TS - 330)), leeway_s=60) + + +def test_max_age_is_configurable(): + key = P256SigningKey.generate(kid="w") + assert _verify(_request(_proof(key, iat=NOW_TS - 3600)), max_age_s=7200) + + +def test_an_expired_proof_is_rejected_through_the_shared_temporal_check(): + key = P256SigningKey.generate(kid="w") + proof = _proof(key, claims_extra={"exp": NOW_TS - 600}) + with pytest.raises(ClaimsInvalid, match="expired"): + _verify(_request(proof)) + + +# ------------------------------------------------------------------- iss / client # + +def test_iss_is_pinned_when_an_expected_client_is_given(): + key = P256SigningKey.generate(kid="w") + proof = _proof(key, claims_extra={"iss": "wallet-client-1"}) + assert _verify(_request(proof), expected_client_id="wallet-client-1") + with pytest.raises(ClaimsInvalid, match="does not match the authenticated client"): + _verify(_request(proof), expected_client_id="someone-else") + + +def test_a_missing_iss_fails_a_pinned_client(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="does not match"): + _verify(_request(_proof(key)), expected_client_id="wallet-client-1") + + +# --------------------------------------------------------------- nonce and replay # + +def test_the_nonce_is_consumed_exactly_once(): + key = P256SigningKey.generate(kid="w") + seen = [] + _verify(_request(_proof(key)), check_nonce=lambda n: seen.append(n) or True) + assert seen == [NONCE] + + +def test_a_replayed_nonce_raises_the_distinct_error(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ProofReplayed, match="already consumed"): + _verify(_request(_proof(key)), check_nonce=lambda n: False) + + +def test_requiring_a_nonce_without_a_store_fails_closed(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="no check_nonce"): + verify_credential_request_proofs( + _request(_proof(key)), credential_issuer=ISSUER, now=NOW) + + +def test_a_missing_nonce_is_rejected_by_default(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="missing the required nonce"): + _verify(_request(_proof(key, nonce=None))) + + +def test_nonce_can_be_opted_out_of_explicitly(): + key = P256SigningKey.generate(kid="w") + verified, = verify_credential_request_proofs( + _request(_proof(key, nonce=None)), credential_issuer=ISSUER, + require_nonce=False, now=NOW) + assert verified.nonce is None + + +def test_the_store_is_never_called_when_a_signature_fails(): + """Consume-after-verify: garbage must not let an attacker burn nonces.""" + from openvc.proof.errors import SignatureInvalid + + key = P256SigningKey.generate(kid="w") + other = P256SigningKey.generate(kid="other") + proof = _proof(key, jwk=other.public_jwk()) + calls = [] + with pytest.raises(SignatureInvalid): + _verify(_request(proof), check_nonce=lambda n: calls.append(n) or True) + assert calls == [] + + +@pytest.mark.parametrize("nonce", ["", 42, [], {}]) +def test_a_malformed_nonce_claim_is_rejected(nonce): + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid): + _verify(_request(_proof(key, nonce=nonce))) + + +# ------------------------------------------------------------- batch invariants # + +def test_a_batch_is_rejected_unless_the_issuer_advertised_one(): + a, b = P256SigningKey.generate(kid="a"), P256SigningKey.generate(kid="b") + with pytest.raises(CredentialRequestMalformed, match="batch limit is 1"): + _verify(_request(_proof(a), _proof(b))) + + +def test_a_permitted_batch_returns_one_result_per_proof(): + a, b = P256SigningKey.generate(kid="a"), P256SigningKey.generate(kid="b") + verified = _verify(_request(_proof(a), _proof(b)), batch_size=2) + assert [v.public_jwk for v in verified] == [a.public_jwk(), b.public_jwk()] + + +def test_the_nonce_is_still_consumed_only_once_for_a_batch(): + a, b = P256SigningKey.generate(kid="a"), P256SigningKey.generate(kid="b") + seen = [] + _verify(_request(_proof(a), _proof(b)), batch_size=2, + check_nonce=lambda n: seen.append(n) or True) + assert seen == [NONCE] + + +def test_proofs_with_divergent_nonces_are_rejected(): + a, b = P256SigningKey.generate(kid="a"), P256SigningKey.generate(kid="b") + with pytest.raises(ClaimsInvalid, match="same nonce"): + _verify(_request(_proof(a), _proof(b, nonce="other-nonce")), batch_size=2) + + +def test_two_proofs_on_the_same_key_are_rejected(): + """N credentials must mean N keys, not N copies bound to one.""" + key = P256SigningKey.generate(kid="w") + with pytest.raises(ClaimsInvalid, match="same key"): + _verify(_request(_proof(key), _proof(key)), batch_size=2) + + +def test_one_bad_proof_rejects_the_whole_batch(): + """There is no partial issuance.""" + good, bad = P256SigningKey.generate(kid="a"), P256SigningKey.generate(kid="b") + with pytest.raises(ClaimsInvalid): + _verify(_request(_proof(good), _proof(bad, aud="https://other.example")), + batch_size=2) + + +# --------------------------------------------------------------- request shape # + +def test_proofs_must_carry_exactly_one_proof_type(): + key = P256SigningKey.generate(kid="w") + body = {"credential_configuration_id": "X", + "proofs": {"jwt": [_proof(key)], "di_vp": [{}]}} + with pytest.raises(CredentialRequestMalformed, match="exactly one proof type"): + _verify(body) + + +def test_an_unsupported_proof_type_is_typed(): + body = {"credential_configuration_id": "X", "proofs": {"di_vp": ["x"]}} + with pytest.raises(UnsupportedProofType, match="di_vp"): + _verify(body) + + +@pytest.mark.parametrize("body", [ + {"proofs": {"jwt": ["x"]}}, # neither id + {"credential_configuration_id": "X", "credential_identifier": "Y", + "proofs": {"jwt": ["x"]}}, # both ids + {"credential_configuration_id": "", "proofs": {"jwt": ["x"]}}, # empty id + {"credential_configuration_id": 42, "proofs": {"jwt": ["x"]}}, # non-string id +]) +def test_the_credential_identifier_contract_is_enforced(body): + with pytest.raises(CredentialRequestMalformed): + _verify(body) + + +@pytest.mark.parametrize("proofs", [ + None, {}, {"jwt": []}, {"jwt": "not-an-array"}, {"jwt": [""]}, {"jwt": [42]}, + {"": ["x"]}, "not-an-object", +]) +def test_a_malformed_proofs_member_is_rejected(proofs): + with pytest.raises(CredentialRequestMalformed): + _verify({"credential_configuration_id": "X", "proofs": proofs}) + + +def test_configuration_ids_can_be_pinned_to_what_the_issuer_offers(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(CredentialRequestMalformed, match="unsupported credential_conf"): + parse_credential_request( + _request(_proof(key)), supported_configuration_ids=["SomethingElse"]) + assert parse_credential_request( + _request(_proof(key)), supported_configuration_ids=["UniversityDegree"]) + + +def test_an_oversized_proof_is_capped_before_parsing(): + body = {"credential_configuration_id": "X", "proofs": {"jwt": ["a" * 20_000]}} + with pytest.raises(CredentialRequestMalformed, match="exceeds"): + _verify(body) + + +def test_a_json_string_body_is_accepted(): + key = P256SigningKey.generate(kid="w") + import json as _json + assert _verify(_json.dumps(_request(_proof(key)))) + + +@pytest.mark.parametrize("body", ["not json", "[]", '"a string"', "null", "123"]) +def test_a_non_object_body_is_rejected(body): + with pytest.raises(CredentialRequestMalformed): + _verify(body) + + +def test_hostile_deeply_nested_json_fails_closed(): + body = '{"credential_configuration_id":"X","proofs":' + '[' * 5000 + ']' * 5000 + '}' + with pytest.raises(CredentialRequestMalformed): + _verify(body) + + +def test_response_encryption_must_be_an_object_when_present(): + key = P256SigningKey.generate(kid="w") + body = dict(_request(_proof(key)), credential_response_encryption="nope") + with pytest.raises(CredentialRequestMalformed, match="must be an object"): + _verify(body) + + +# ------------------------------------------------------------- caller contract # + +def test_an_empty_credential_issuer_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(CredentialRequestMalformed, match="credential_issuer"): + _verify(_request(_proof(key)), credential_issuer="") + + +@pytest.mark.parametrize("kw", [{"max_age_s": -1}, {"leeway_s": -1}]) +def test_negative_time_windows_are_rejected(kw): + key = P256SigningKey.generate(kid="w") + with pytest.raises(CredentialRequestMalformed, match="must not be negative"): + _verify(_request(_proof(key)), **kw) + + +def test_a_zero_batch_size_is_rejected(): + key = P256SigningKey.generate(kid="w") + with pytest.raises(CredentialRequestMalformed, match="at least 1"): + _verify(_request(_proof(key)), batch_size=0) + + +def test_key_attestation_is_captured_but_not_verified(): + key = P256SigningKey.generate(kid="w") + proof = _proof(key, header_extra={"key_attestation": "ey.unverified.token"}) + verified, = _verify(_request(proof)) + assert verified.key_attestation == "ey.unverified.token" + + +def test_verified_proof_is_frozen(): + key = P256SigningKey.generate(kid="w") + verified, = _verify(_request(_proof(key))) + with pytest.raises(Exception): + verified.public_jwk = {} diff --git a/tests/test_return_contract.py b/tests/test_return_contract.py index f6ff063..844b4d9 100644 --- a/tests/test_return_contract.py +++ b/tests/test_return_contract.py @@ -14,6 +14,7 @@ import pytest +from openvc.openid4vci import CredentialRequest, VerifiedProof from openvc.proof.data_integrity import VerifiedDataIntegrity from openvc.proof.ecdsa_sd import VerifiedSdCredential from openvc.proof.sd_jwt import VerifiedSdJwt @@ -35,8 +36,23 @@ VerifiedSdCredential: ["credential", "issuer", "subject", "proof"], VerifiedSdJwt: ["claims", "issuer", "vct", "key_bound", "confirmation"], VerifiedPresentation: ["holder", "credentials", "claims", "vp"], + VerifiedProof: ["public_jwk", "thumbprint", "alg", "key_source", "issued_at", + "nonce", "client_id", "key_attestation", "header", "claims"], + CredentialRequest: ["credential_configuration_id", "credential_identifier", + "proof_type", "proofs", "response_encryption", "raw"], } +# Dataclasses whose EVERY field must default — the add-only rule. A consumer that +# constructs one positionally keeps working when a field is appended. +ADD_ONLY = [VerifiedProof, CredentialRequest] + + +@pytest.mark.parametrize("cls", ADD_ONLY, ids=[c.__name__ for c in ADD_ONLY]) +def test_every_field_defaults_so_the_shape_stays_add_only(cls): + missing = [f.name for f in dc.fields(cls) + if f.default is dc.MISSING and f.default_factory is dc.MISSING] + assert not missing, f"{cls.__name__} fields without a default break add-only: {missing}" + @pytest.mark.parametrize("cls,expected", list(CONTRACT.items()), ids=[c.__name__ for c in CONTRACT]) diff --git a/wiki/Home.md b/wiki/Home.md index 05c264f..a018ca4 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -34,6 +34,7 @@ so the snippets you read here are guaranteed to run against the current release. | [Data Integrity](Data-Integrity) | the five cryptosuites, JCS without `pyld`, `ecdsa-sd-2023` | | [Presentations & OpenID4VP](Presentations) | VP-JWT, `vp_token` + DCQL, HAIP encrypted responses | | [Resolving issuer keys](Resolving-Issuer-Keys) | `did:key` / `did:jwk` / `did:web`, well-known, caching | +| [Issuing with OpenID4VCI](Issuing-with-OpenID4VCI) | verify a wallet's key proof, then issue bound to the key it proved | | [Status lists](Status-Lists) | revocation — check *and* issue, both encodings | | [Trust anchors](Trust) | X.509 `x5c`, EU Trusted Lists, the EBSI plugin | | [Async verification](Async-Verification) | `verify_credential_async` for asyncio servers | @@ -48,6 +49,7 @@ src/openvc/ core — knows nothing about EBSI or badges verify.py verify_credential / verify_many: the one-call pipeline aio.py verify_credential_async / verify_many_async openid4vp.py verify_vp_token: stateless OpenID4VP 1.0 verifier + openid4vci.py OpenID4VCI 1.0 issuer side: wallet key-proof verification jwe.py decrypt_compact: JWE ECDH-ES decrypt (HAIP responses) mdoc.py ISO 18013-5 mso_mdoc verification (experimental) cose.py / cbor.py hand-rolled COSE_Sign1/Mac0 + bounded CBOR (mdoc, no deps) diff --git a/wiki/Issuing-with-OpenID4VCI.md b/wiki/Issuing-with-OpenID4VCI.md new file mode 100644 index 0000000..65e0f28 --- /dev/null +++ b/wiki/Issuing-with-OpenID4VCI.md @@ -0,0 +1,180 @@ +# Issuing with OpenID4VCI + +openvc verifies the **key proof** a wallet sends to your Credential Endpoint, and hands +you back the public key it demonstrated possession of. That key is what you bind the +credential to. + +It does **not** run the endpoint. Per +[ADR-0007](https://github.com/luisgf/openvc/blob/main/docs/adr/ADR-0007-oid4vci-issuer-side.md), +the split is: + +> **openvc:** attacker-controlled bytes that must be verified or parsed fail-closed. +> **You:** anything with a lifetime, a socket, or a deployment policy. + +So the OAuth Authorization Server, the HTTP routing, the `c_nonce` store, the Credential +Response body, deferred and notification bookkeeping, and DPoP are yours (or your +framework's). What this supports claiming is *OpenID4VCI 1.0 key-proof verification* — +not "issuance", and not HAIP. + +## The whole flow + +```python +import time + +from openvc.keys import Ed25519SigningKey, P256SigningKey +from openvc.openid4vci import verify_credential_request_proofs +from openvc.proof._jws import sign_compact +from openvc.proof.sd_jwt import SdJwtVcProofSuite + +CREDENTIAL_ISSUER = "https://issuer.example" +issuer = Ed25519SigningKey.generate(kid=f"{CREDENTIAL_ISSUER}#key-1") + +# Your nonce store. See below — this MUST be atomic. +issued = {"c-nonce-abc"} +def consume_nonce(nonce): + try: + issued.remove(nonce) + return True + except KeyError: + return False + +# --- the wallet mints a key proof (OID4VCI 1.0 App. F.1) --- +wallet = P256SigningKey.generate(kid="wallet-key-1") +key_proof = sign_compact( + {"typ": "openid4vci-proof+jwt", "alg": wallet.alg, "jwk": wallet.public_jwk()}, + {"aud": CREDENTIAL_ISSUER, "iat": int(time.time()), "nonce": "c-nonce-abc"}, + signing_key=wallet) + +# --- your Credential Endpoint verifies it, then issues --- +proof, = verify_credential_request_proofs( + {"credential_configuration_id": "UniversityDegree", + "proofs": {"jwt": [key_proof]}}, + credential_issuer=CREDENTIAL_ISSUER, + check_nonce=consume_nonce) + +sd_jwt = SdJwtVcProofSuite().issue( + {"iss": CREDENTIAL_ISSUER, "degree": "BSc"}, signing_key=issuer, + vct="https://credentials.example/degree", + holder_jwk=proof.public_jwk) # <- the key the proof earned + +assert proof.key_source == "jwk" +``` + +Wire it into your framework however you like — openvc never sees the request object, +the access token, or the socket: + + +```python +@app.post("/credential") +def credential_endpoint(request): + grant = my_as.introspect(request.headers["authorization"]) # yours: OAuth AS + proofs = verify_credential_request_proofs( + request.json(), credential_issuer=CREDENTIAL_ISSUER, + check_nonce=my_nonce_store.consume) + sd_jwt = SdJwtVcProofSuite().issue( + grant.claims, signing_key=ISSUER_KEY, vct=VCT, holder_jwk=proofs[0].public_jwk) + return {"credentials": [{"credential": sd_jwt}]} # yours: the body +``` + +## The nonce store is yours, and it must be atomic + +`check_nonce` is a **required** parameter. 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. + +Your callable must mark the nonce used and report validity in **one** step: a Redis +`SET key val NX`, a SQL `DELETE … RETURNING`. The bug to avoid: + + +```python +# WRONG — two concurrent requests both observe the nonce as unused. +if nonce in store: + store.discard(nonce) + return True +``` + +`openvc.cache.TtlCache` is **not** suitable: it documents its own lack of single-flight, +which is benign for a read cache and fatal for a single-use token. + +openvc calls it **exactly once per request**, and only **after** every signature has +verified — so an unauthenticated attacker cannot burn your nonces by spraying garbage. + +Pre-authorized codes, `transaction_id` and `notification_id` are entirely yours: they +are opaque identifiers with no bytes for openvc to get right, so it does not generate +them. + +## What is checked, in order + +Structure and allow-lists run before any cryptography. **Any failure rejects the whole +request** — there is no partial issuance. + +| | Check | +|---|---| +| 1 | `typ` is `openid4vci-proof+jwt` — so a KB-JWT, VP-JWT or status-list token cannot be replayed as a key proof | +| 2 | `alg` is allow-listed (`ES256`/`ES384`/`EdDSA`/`Ed25519`), **before** any crypto | +| 3 | unknown `crit` rejected | +| 4 | **exactly one** of `jwk` / `kid` / `x5c` / `trust_chain` | +| 5 | the key binds to the header `alg` — no `ES256` over an Ed25519 key; no private members in a `jwk` | +| 6 | the signature | +| 7 | `aud` equals your Credential Issuer Identifier; a multi-valued `aud` is rejected | +| 8 | `iat` freshness — **both** stale and future-dated | +| 9 | `exp` / `nbf` if present; `iss` if you pinned `expected_client_id` | +| 10 | across the batch: one shared nonce, consumed once, no two proofs on the same key | + +Two of these carry the weight. **`iat` in both directions**: without the future-dated +check a wallet signs once with `iat = now + 10y` and holds a proof that never expires. +**Exactly one key parameter**: two present lets an attacker pair a `kid` naming an +honest key with a `jwk` they control, and any implementation that silently prefers one +accepts it. + +## Key parameters + +`jwk` works out of the box. The other two are opt-in, and fail closed without their +enabling argument: + +| Header | Enable with | Absent ⇒ | +|---|---|---| +| `jwk` | — | always available | +| `x5c` | `trust_anchors=[...]` | rejected — an unanchored chain is decoration, not trust | +| `kid` | `resolve_proof_key=` | rejected | +| `trust_chain` | — | typed `UnsupportedProofType` (OpenID Federation is out of scope) | + +## Batches + +`batch_size` defaults to **1**, so an issuer that never advertised +`batch_credential_issuance` rejects a batch rather than minting one credential per proof +off a single grant. Raise it deliberately: + + +```python +proofs = verify_credential_request_proofs( + body, credential_issuer=CREDENTIAL_ISSUER, + check_nonce=store.consume, batch_size=5) +``` + +All proofs in a batch must carry the same nonce, and no two may be bound to the same +key — N credentials must mean N keys. + +## Errors + +| Raised | Meaning | Map to | +|---|---|---| +| `CredentialRequestMalformed` | the §8.2 wire contract is violated | `invalid_credential_request` | +| `ProofReplayed` | your store rejected the nonce | `invalid_nonce` — hand out a fresh one and let the wallet retry | +| `UnsupportedProofType` | `di_vp`, `trust_chain`, … | `invalid_proof` | +| `ClaimsInvalid` / `SignatureInvalid` / `MalformedToken` / `UnsupportedAlgorithm` | the shared proof leaves | `invalid_proof` | + +`ProofReplayed` is deliberately **not** a `ClaimsInvalid`: "here is a fresh nonce, try +again" is a different answer from "your proof is wrong". + +## Not covered + +Key attestations are captured verbatim in `VerifiedProof.key_attestation` and are +**unverified** — their trust model needs a wallet-provider anchor class of its own. +`di_vp` proofs, credential-response encryption, mdoc issuance and DPoP are out of scope +(ADR-0007 D9). HAIP additionally requires DPoP, key attestations and client +authentication, all of which live in your endpoint. + +See also: [SD-JWT VC](SD-JWT-VC) for the issuance side, [Keys & HSM +backends](Keys-and-HSM) for `SigningKey`, and [Security model](Security-Model). diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index a87256c..5d0e4cd 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -20,6 +20,10 @@ - [Trust anchors: EU TL & EBSI](Trust) - [Async verification](Async-Verification) +**Issuing** + +- [Issuing with OpenID4VCI](Issuing-with-OpenID4VCI) + **Walkthroughs** - [Spanish university credential](Spanish-University-Credential)