Skip to content

Verification pipeline

The one-call verifier: detect the format, resolve the issuer key, verify the proof, and apply policy.

openvc.verify

openvc.verify — the generic verification pipeline.

One call, :func:verify_credential, that

  1. detects the format — VC-JWT, SD-JWT VC, Data Integrity (eddsa-rdfc-2022 / ecdsa-sd-2023 over RDF, or eddsa-jcs-2022 / ecdsa-jcs-2019 over RFC 8785 JCS), or an enveloped VCDM 2.0 credential;
  2. resolves the issuer key via a :class:~openvc.did.base.DidResolverRegistry (JOSE formats peek the untrusted iss/kid; Data Integrity resolves the proof's verificationMethod);
  3. verifies the proof through the matching suite; and
  4. applies a :class:VerificationPolicy — expected type(s)/vct, audience and holder-binding for SD-JWT, proofPurpose for Data Integrity, and status-list revocation.

It turns the per-format proof suites into a single verifier. The EBSI glue (:func:openvc_ebsi.verify.verify_ebsi_badge) is a specialisation of this shape with the extra TIR trust step.

Status is fail-closed by default (policy.require_status). Every format is checked against both status conventions — the W3C credentialStatus and the IETF token status reference — so a status declared in the shape that does not match the proof format is not silently skipped. If a status is declared and no matching resolver is supplied, verification fails (:class:StatusUnavailable) rather than skipping revocation — you opt out explicitly, not by omission. A resolved revoked status raises :class:~openvc.status.CredentialRevoked; a suspended one raises :class:~openvc.status.CredentialSuspended.

Selective disclosure caveat: for SD-JWT VC and ecdsa-sd-2023 the verifier only sees what the holder discloses. A holder can withhold the credentialStatus / status claim entirely, in which case there is nothing to check and the fail-closed gate cannot fire. An issuer that wants status enforced must make it mandatory / non-selectively-disclosable (mark the pointer mandatory for ecdsa-sd; keep it outside disclosable for SD-JWT).

Data Integrity issuer binding: the embedded-proof formats are accepted only when the proof's verificationMethod is controlled by the credential's issuer (same DID) — otherwise anyone could sign a credential naming an arbitrary issuer with their own key (:class:IssuerBindingError).

Errors: a proof/temporal/purpose failure raises a :class:~openvc.proof.errors.ProofError subclass (shared by every suite); a pipeline-level failure (unknown format, key resolution, type mismatch, missing status resolver) raises a :class:VerificationError subclass; a revoked credential raises :class:~openvc.status.CredentialRevoked. A key-resolution failure is a :class:KeyResolutionFailed on every lane: the JOSE lanes raise it, and the Data Integrity lanes (sync and async alike) raise its subclass :class:~openvc.proof.errors.KeyResolutionError, which is also a ProofError, so except KeyResolutionFailed, except VerificationError and except ProofError each catch a Data Integrity key-resolution failure. (The asynchronous API raises the same classes as the synchronous one.)

KeyResolutionFailed

Bases: VerificationError

The issuer's (or holder's) key could not be resolved: the DID or its document could not be fetched or parsed, the DID method has no resolver, or the document does not hold the key the credential names.

Every verification lane raises this class or a subclass of it. The Data Integrity lanes raise the subclass :class:~openvc.proof.errors.KeyResolutionError, which is also a :class:~openvc.proof.errors.ProofError.

Source code in src/openvc/errors.py
35
36
37
38
39
40
41
42
43
44
class KeyResolutionFailed(VerificationError):
    """The issuer's (or holder's) key could not be resolved: the DID or its document
    could not be fetched or parsed, the DID method has no resolver, or the document does
    not hold the key the credential names.

    Every verification lane raises this class or a subclass of it. The Data Integrity
    lanes raise the subclass :class:`~openvc.proof.errors.KeyResolutionError`, which is
    also a :class:`~openvc.proof.errors.ProofError`."""

    __module__ = "openvc.verify"

VerificationError

Bases: OpenvcError

A pipeline-level failure (not a proof-signature failure).

Source code in src/openvc/errors.py
27
28
29
30
31
32
class VerificationError(OpenvcError):
    """A pipeline-level failure (not a proof-signature failure)."""

    # defined here so openvc.proof can subclass it, but still reported as the
    # openvc.verify class it was before: tracebacks, repr and pickle are unchanged
    __module__ = "openvc.verify"

StatusListIssuerUntrusted

Bases: VerificationError

The resolved status list's issuer is neither the credential's issuer nor an allow-listed delegate, and require_status_issuer_binding is set (ADR-0006).

Source code in src/openvc/verify.py
127
128
129
class StatusListIssuerUntrusted(VerificationError):
    """The resolved status list's issuer is neither the credential's issuer nor an
    allow-listed delegate, and ``require_status_issuer_binding`` is set (ADR-0006)."""

StatusUnavailable

Bases: VerificationError

The credential declares a status but no resolver was given and require_status is set (fail-closed).

Source code in src/openvc/verify.py
132
133
134
class StatusUnavailable(VerificationError):
    """The credential declares a status but no resolver was given and
    ``require_status`` is set (fail-closed)."""

InvalidPolicy

Bases: ProofError

A :class:VerificationPolicy knob is out of range: leeway_s is not a whole number of seconds from 0 to MAX_LEEWAY_S (one day), or kb_max_age_s is not a non-negative int. Raised where the policy is built, so a misconfiguration fails once, before any credential is read, instead of once per credential. It is a :class:~openvc.proof.errors.ProofError, the family the per-suite check raises.

Source code in src/openvc/verify.py
137
138
139
140
141
142
class InvalidPolicy(ProofError):
    """A :class:`VerificationPolicy` knob is out of range: ``leeway_s`` is not a whole
    number of seconds from 0 to ``MAX_LEEWAY_S`` (one day), or ``kb_max_age_s`` is not a
    non-negative ``int``. Raised where the policy is built, so a misconfiguration fails
    once, before any credential is read, instead of once per credential. It is a
    :class:`~openvc.proof.errors.ProofError`, the family the per-suite check raises."""

VerificationPolicy dataclass

What to assert beyond the signature. All fields have safe defaults.

require_status is True by default (fail-closed): a credential that declares a status but is verified without a resolver is rejected. require_schema is False by default (opt-in): credentialSchema is validated only when a resolve_credential_schema fetch is supplied; set it True to reject a credential that declares a schema but is verified without one. now pins the evaluation instant for Data Integrity temporal checks and for the SD-JWT Key Binding JWT freshness window (the JOSE suites' exp/nbf, and the VCDM validFrom/validUntil they enforce, use the current time). kb_max_age_s bounds how old a KB-JWT's iat may be (RFC 9901 §7.3 step 5.5). audience and nonce bind an SD-JWT KB-JWT together (step 5.6): a KB-JWT checked against only one of them, or an empty one, is rejected (ClaimsInvalid), while audience alone still pins a VC-JWT's aud. That does not make the KB-JWT required — require_key_binding does. audience is also the audience an SD-JWT VC issuer JWT's own aud must name, when it has one (RFC 7519 §4.1.3; nested in a presentation, that presentation's audience). expected_types pins a W3C credential's type (a bare string is one type); an IETF SD-JWT VC is typed by vct, so a policy that sets it rejects one — pin those with expected_vct. On the RDF Data Integrity suites a name matches by IRI, resolved through the contexts the verifier serves (bundled or extra_contexts), never the credential's own @context: a type only an inline @vocab names needs a served context naming it, or its full IRI.

leeway_s is the clock-skew tolerance every lane adds to its temporal checks: a whole number of seconds from 0 to MAX_LEEWAY_S (86 400, one day). A float (60.5, NaN, Infinity), a negative, a bool, a string, None or anything wider raises :class:InvalidPolicy when the policy is built; to verify as of another date, pin now. kb_max_age_s must be a non-negative int.

Source code in src/openvc/verify.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
@dataclass(frozen=True)
class VerificationPolicy:
    """What to assert beyond the signature. All fields have safe defaults.

    ``require_status`` is **True** by default (fail-closed): a credential that
    declares a status but is verified without a resolver is rejected.
    ``require_schema`` is **False** by default (opt-in): ``credentialSchema`` is
    validated only when a ``resolve_credential_schema`` fetch is supplied; set it
    True to reject a credential that declares a schema but is verified without
    one. ``now`` pins the evaluation instant for Data Integrity temporal checks and
    for the SD-JWT Key Binding JWT freshness window (the JOSE suites' ``exp``/``nbf``,
    and the VCDM ``validFrom``/``validUntil`` they enforce, use the current time).
    ``kb_max_age_s`` bounds how old a KB-JWT's ``iat`` may be (RFC 9901 §7.3 step
    5.5). ``audience`` and ``nonce`` bind an SD-JWT KB-JWT together (step 5.6): a
    KB-JWT checked against only one of them, or an empty one, is rejected
    (``ClaimsInvalid``), while ``audience`` alone still pins a VC-JWT's ``aud``. That
    does not make the KB-JWT required — ``require_key_binding`` does. ``audience`` is also
    the audience an SD-JWT VC issuer JWT's own ``aud`` must name, when it has one (RFC 7519
    §4.1.3; nested in a presentation, that presentation's audience).
    ``expected_types`` pins a W3C credential's ``type`` (a bare string is one type); an
    IETF SD-JWT VC is typed by ``vct``, so a policy that sets it rejects one — pin those
    with ``expected_vct``. On the RDF Data Integrity suites a name matches by IRI,
    resolved through the contexts the verifier serves (bundled or ``extra_contexts``),
    never the credential's own ``@context``: a type only an inline ``@vocab`` names
    needs a served context naming it, or its full IRI.

    ``leeway_s`` is the clock-skew tolerance every lane adds to its temporal checks: a
    whole number of seconds from 0 to ``MAX_LEEWAY_S`` (86 400, one day). A float
    (``60.5``, ``NaN``, ``Infinity``), a negative, a ``bool``, a string, ``None`` or
    anything wider raises :class:`InvalidPolicy` when the policy is built; to verify as
    of another date, pin ``now``. ``kb_max_age_s`` must be a non-negative ``int``."""
    leeway_s: int = DEFAULT_LEEWAY_S
    expected_types: Sequence[str] | None = None
    expected_vct: str | None = None
    audience: str | None = None
    nonce: str | None = None
    require_key_binding: bool = False
    proof_purpose: str = "assertionMethod"
    require_status: bool = True
    require_schema: bool = False
    now: datetime | None = None
    # Status-list issuer trust (ADR-0006). Off by default (a status list is authenticated
    # but its issuer is unconstrained — delegation is spec-legal). Opt in to bind the status
    # list's issuer to the credential's issuer; the allow-list adds trusted delegates.
    require_status_issuer_binding: bool = False
    status_issuer_allowlist: frozenset[str] | None = None
    # Independent of authenticity. False still verifies the signature, issuer,
    # holder-binding, nbf and status *list* — it only stops a matching
    # expired/revoked *disposition* from failing the call. Default True
    # (fail-closed). JOSE ``now`` is still wall-clock unless a suite pins it.
    require_not_expired: bool = True
    require_not_revoked: bool = True
    # How old an SD-JWT KB-JWT's `iat` may be, evaluated at `now` (RFC 9901 §7.3 step
    # 5.5). Any KB-JWT present is held to it. To re-verify an archived one, pin `now`
    # to the receipt time your system recorded — never a value read from the token.
    kb_max_age_s: int = DEFAULT_KB_MAX_AGE_S

    def __post_init__(self) -> None:
        leeway_seconds(self.leeway_s, error=InvalidPolicy)
        non_negative_seconds(self.kb_max_age_s, name="kb_max_age_s", error=InvalidPolicy)

VerificationResult dataclass

The outcome of a successful :func:verify_credential.

Source code in src/openvc/verify.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
@dataclass(frozen=True)
class VerificationResult:
    """The outcome of a successful :func:`verify_credential`."""
    format: str
    credential: dict[str, Any]                 # the VC (SD-JWT: the disclosed claims)
    issuer: str | None
    subject: str | None
    claims: dict[str, Any] | None = None       # full JWT/SD-JWT claim set, if any
    key_bound: bool | None = None              # SD-JWT: a valid KB-JWT verified
    status: Union[StatusResult, TokenStatusResult, None] = None
    schema: SchemaValidationResult | None = None  # credentialSchema validation, if run
    raw: Any = None                            # the underlying suite result
    # True when ``require_not_expired`` waived an expiry the default policy would
    # have rejected: the proof suite reports the check it skipped (``raw.expired``),
    # with the same clock and fields it enforces. Always False when the policy
    # enforces expiry. Default False so existing constructions stay valid (add-only).
    expired: bool = False

BatchResult dataclass

One credential's outcome in a :func:verify_many batch. On success result is set and error is None; on a fail-closed verification failure error is set and result is None. index is the credential's position in the input sequence, and ok is derived from error so it can never disagree with it.

Source code in src/openvc/verify.py
252
253
254
255
256
257
258
259
260
261
262
263
264
@dataclass(frozen=True)
class BatchResult:
    """One credential's outcome in a :func:`verify_many` batch. On success *result* is set
    and *error* is ``None``; on a fail-closed verification failure *error* is set and
    *result* is ``None``. *index* is the credential's position in the input sequence, and
    *ok* is **derived** from *error* so it can never disagree with it."""
    index: int
    result: VerificationResult | None = None
    error: OpenvcError | None = None

    @property
    def ok(self) -> bool:
        return self.error is None

default_resolver()

A :class:DidResolverRegistry with the offline did:key and did:jwk resolvers and the SSRF-guarded did:web and did:webvh resolvers — the pipeline's default when none is passed. (did:web / did:webvh only reach the network when such a DID is resolved.)

Source code in src/openvc/verify.py
271
272
273
274
275
276
277
278
279
280
281
282
def default_resolver() -> "DidResolverRegistry":
    """A :class:`DidResolverRegistry` with the offline ``did:key`` and ``did:jwk``
    resolvers and the SSRF-guarded ``did:web`` and ``did:webvh`` resolvers — the
    pipeline's default when none is passed. (``did:web`` / ``did:webvh`` only reach the
    network when such a DID is resolved.)"""
    from .did.base import DidResolverRegistry
    from .did.did_jwk import DidJwkResolver
    from .did.did_key import DidKeyResolver
    from .fetch import default_did_web_resolver, default_did_webvh_resolver
    return DidResolverRegistry(
        [DidKeyResolver(), DidJwkResolver(),
         default_did_web_resolver(), default_did_webvh_resolver()])

detect_format(credential)

Classify credential into one of the FORMAT_* tags. Raises :class:UnknownCredentialFormat if it matches nothing.

Source code in src/openvc/verify.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
def detect_format(credential: Any) -> str:
    """Classify *credential* into one of the ``FORMAT_*`` tags. Raises
    :class:`UnknownCredentialFormat` if it matches nothing."""
    if isinstance(credential, str):
        if "~" in credential:
            return FORMAT_SD_JWT_VC              # SD-JWT: issuer-jwt ~ disclosures ~ kb
        if credential.count(".") == 2:
            return FORMAT_VC_JWT                 # a compact JWS
        raise UnknownCredentialFormat("string is neither a compact JWS nor an SD-JWT")

    if isinstance(credential, dict):
        types = credential.get("type", [])
        types = [types] if isinstance(types, str) else types
        if not isinstance(types, (list, tuple)):         # VCDM: a string or an array; a
            raise UnknownCredentialFormat(               # null/number/bool/object is malformed
                f"credential `type` must be a string or an array, "
                f"not {type(types).__name__}")
        if "EnvelopedVerifiableCredential" in types:
            return FORMAT_ENVELOPED
        proof = credential.get("proof")
        if isinstance(proof, dict):
            cryptosuite = proof.get("cryptosuite")
            fmt = _CRYPTOSUITE_FORMAT.get(cryptosuite) if isinstance(cryptosuite, str) else None
            if fmt is None:
                # name a non-str by its type: repr() of a deeply nested one raises a
                # bare RecursionError
                shown = (repr(cryptosuite) if isinstance(cryptosuite, str)
                         else type(cryptosuite).__name__)
                raise UnknownCredentialFormat(
                    f"unsupported Data Integrity cryptosuite {shown}")
            return fmt
        raise UnknownCredentialFormat(
            "dict has no `proof` and is not an EnvelopedVerifiableCredential")

    raise UnknownCredentialFormat(f"cannot verify a {type(credential).__name__}")

verify_credential(credential, *, policy=None, resolver=None, resolve_status_list=None, resolve_status_list_token=None, resolve_credential_schema=None, jwt_vc_issuer_fetch=None, x5c_trust_anchors=None, extra_contexts=None, _depth=0, _kb_binding=None)

Verify a credential in any supported format against policy.

resolver (a DidResolver / DidResolverRegistry) resolves a DID issuer key; it defaults to :func:default_resolver (offline did:key / did:jwk + SSRF-guarded did:web). jwt_vc_issuer_fetch opts into https issuer key discovery: when a JOSE credential's iss is an https URL, its key is resolved from /.well-known/jwt-vc-issuer using this fetch callable (pass :func:openvc.fetch.https_json_fetch for the SSRF-guarded one) — omit it and an https issuer raises. x5c_trust_anchors (a sequence of trusted root x509.Certificate objects) opts into X.509 issuer trust: when a JOSE header carries an x5c chain, it is validated to those anchors and bound to iss before its leaf key is used (see :mod:openvc.x5c). resolve_status_list fetches and verifies a W3C status-list credential (VC-JWT / Data Integrity); resolve_status_list_token the same for an IETF status-list token (SD-JWT). resolve_credential_schema opts into JSON Schema validation: when the credential declares a credentialSchema (JsonSchema type), its schema is fetched with this callable (pass :func:openvc.fetch.https_json_fetch) and the credential validated against it — omit it and the schema is not checked unless policy.require_schema is set (see :mod:openvc.schema). extra_contexts is passed to the Data Integrity canonicaliser for offline non-bundled contexts.

The status/schema resolvers are caller-injected, so openvc.fetch's SSRF guard protects those issuer-named URLs only if you pass a guarded fetch. Use the blessed defaults in :mod:openvc.resolvers (which fetch through the guarded https fetch and verify the fetched status list) for the safe drop-in; a custom resolver opts out of the guard.

Source code in src/openvc/verify.py
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
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
def verify_credential(
    credential: Any,
    *,
    policy: VerificationPolicy | None = None,
    resolver: Any = None,
    resolve_status_list: Any = None,
    resolve_status_list_token: Any = None,
    resolve_credential_schema: Any = None,
    jwt_vc_issuer_fetch: Any = None,
    x5c_trust_anchors: Any = None,
    extra_contexts: Any = None,
    _depth: int = 0,
    _kb_binding: tuple[str, str] | None = None,
) -> VerificationResult:
    """Verify a credential in any supported format against *policy*.

    *resolver* (a ``DidResolver`` / ``DidResolverRegistry``) resolves a DID issuer
    key; it defaults to :func:`default_resolver` (offline ``did:key`` / ``did:jwk``
    + SSRF-guarded ``did:web``). *jwt_vc_issuer_fetch* opts into **https issuer**
    key discovery: when a JOSE credential's ``iss`` is an https URL, its key is
    resolved from ``/.well-known/jwt-vc-issuer`` using this fetch callable (pass
    :func:`openvc.fetch.https_json_fetch` for the SSRF-guarded one) — omit it and an
    https issuer raises. *x5c_trust_anchors* (a sequence of trusted root
    ``x509.Certificate`` objects) opts into **X.509 issuer trust**: when a JOSE
    header carries an ``x5c`` chain, it is validated to those anchors and bound to
    ``iss`` before its leaf key is used (see :mod:`openvc.x5c`). *resolve_status_list*
    fetches and **verifies** a W3C status-list credential (VC-JWT / Data Integrity);
    *resolve_status_list_token* the same for an IETF status-list token (SD-JWT).
    *resolve_credential_schema* opts into **JSON Schema validation**: when the
    credential declares a ``credentialSchema`` (``JsonSchema`` type), its schema is
    fetched with this callable (pass :func:`openvc.fetch.https_json_fetch`) and the
    credential validated against it — omit it and the schema is not checked unless
    ``policy.require_schema`` is set (see :mod:`openvc.schema`).
    *extra_contexts* is passed to the Data Integrity canonicaliser for offline
    non-bundled contexts.

    The status/schema resolvers are caller-injected, so ``openvc.fetch``'s SSRF
    guard protects those issuer-named URLs only if you pass a guarded fetch. Use the
    blessed defaults in :mod:`openvc.resolvers` (which fetch through the guarded
    https fetch and verify the fetched status list) for the safe drop-in; a custom
    resolver opts out of the guard.
    """
    policy = policy or VerificationPolicy()
    resolver = resolver if resolver is not None else default_resolver()

    _reject_deep_credential(credential)
    fmt = detect_format(credential)
    logger.debug("verify: format=%s", fmt)
    with span("openvc.verify_credential", format=fmt):
        try:
            # _kb_binding is internal: the (audience, nonce) of the presentation this
            # credential is nested in (VP-JWT / ldp_vc). Only the SD-JWT handler reads
            # it — a KB-JWT the credential carries must be bound to that same exchange.
            result = _verify_by_format(
                credential, fmt, policy, resolver, resolve_status_list,
                resolve_status_list_token, resolve_credential_schema,
                jwt_vc_issuer_fetch, x5c_trust_anchors, extra_contexts, _depth,
                _kb_binding)
        except OpenvcError as exc:              # which check failed, no secrets
            logger.info("verify failed: format=%s error=%s", fmt, type(exc).__name__)
            raise
        except RecursionError as exc:           # backstop: a value nested past the stack
            logger.info("verify failed: format=%s error=RecursionError", fmt)
            raise UnknownCredentialFormat(
                "credential is nested too deeply to verify") from exc
        logger.debug("verify ok: format=%s issuer=%s", result.format, result.issuer)
    return result

verify_many(credentials, *, policy=None, resolver=None, resolve_status_list=None, resolve_status_list_token=None, resolve_credential_schema=None, jwt_vc_issuer_fetch=None, x5c_trust_anchors=None, extra_contexts=None)

Verify many credentials in one call, resolving each distinct issuer DID / status list / schema / issuer-metadata URL once — roughly O(distinct issuers), not O(credentials). A 5-credential batch from one issuer resolves that DID once and fetches+verifies a shared status list once, via per-call caches (:func:openvc.cache.batch_resolvers), discarded when the call returns.

Each credential is verified independently and fail-closed: a failure in one (bad signature, revoked, unresolvable key, malformed, …) becomes that item's error and never aborts the others. Returns one :class:BatchResult per input credential, in input order. Every keyword means exactly what it does in :func:verify_credential.

Verification is sequential — the win is deduplicated resolution (each distinct issuer / status list resolved once), not parallelism; distinct issuers are still resolved one at a time.

Source code in src/openvc/verify.py
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
559
560
561
562
563
564
565
566
567
568
569
570
571
def verify_many(
    credentials: Sequence[Any],
    *,
    policy: VerificationPolicy | None = None,
    resolver: Any = None,
    resolve_status_list: Any = None,
    resolve_status_list_token: Any = None,
    resolve_credential_schema: Any = None,
    jwt_vc_issuer_fetch: Any = None,
    x5c_trust_anchors: Any = None,
    extra_contexts: Any = None,
) -> list[BatchResult]:
    """Verify many credentials in one call, resolving each distinct issuer DID / status
    list / schema / issuer-metadata URL **once** — roughly O(distinct issuers), not
    O(credentials). A 5-credential batch from one issuer resolves that DID once and
    fetches+verifies a shared status list once, via per-call caches
    (:func:`openvc.cache.batch_resolvers`), discarded when the call returns.

    Each credential is verified **independently and fail-closed**: a failure in one (bad
    signature, revoked, unresolvable key, malformed, …) becomes that item's ``error`` and
    never aborts the others. Returns one :class:`BatchResult` per input credential, in
    input order. Every keyword means exactly what it does in :func:`verify_credential`.

    Verification is **sequential** — the win is deduplicated resolution (each distinct
    issuer / status list resolved once), not parallelism; distinct issuers are still
    resolved one at a time.
    """
    resolver = resolver if resolver is not None else default_resolver()
    shared = batch_resolvers(
        resolver,
        resolve_status_list=resolve_status_list,
        resolve_status_list_token=resolve_status_list_token,
        resolve_credential_schema=resolve_credential_schema,
        jwt_vc_issuer_fetch=jwt_vc_issuer_fetch,
    )
    out: list[BatchResult] = []
    ok_count = 0
    with span("openvc.verify_many", count=len(credentials)):
        for index, credential in enumerate(credentials):
            try:
                result = verify_credential(
                    credential, policy=policy, x5c_trust_anchors=x5c_trust_anchors,
                    extra_contexts=extra_contexts, **shared)
                out.append(BatchResult(index=index, result=result))
                ok_count += 1               # counted here so the debug args stay O(1)
            except OpenvcError as exc:          # per-credential fail-closed; one bad
                out.append(BatchResult(index=index, error=exc))  # item never aborts the rest
    logger.debug("verify_many: %d credentials, %d ok", len(out), ok_count)
    return out