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.

VerificationError

Bases: OpenvcError

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

Source code in src/openvc/verify.py
105
106
class VerificationError(OpenvcError):
    """A pipeline-level failure (not a proof-signature failure)."""

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
115
116
117
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
120
121
122
class StatusUnavailable(VerificationError):
    """The credential declares a status but no resolver was given and
    ``require_status`` is set (fail-closed)."""

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 (the JOSE suites use the current time).

Source code in src/openvc/verify.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
@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
    (the JOSE suites use the current time)."""
    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

VerificationResult dataclass

The outcome of a successful :func:verify_credential.

Source code in src/openvc/verify.py
157
158
159
160
161
162
163
164
165
166
167
168
@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

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
171
172
173
174
175
176
177
178
179
180
181
182
183
@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
190
191
192
193
194
195
196
197
198
199
200
201
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
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
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 "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:
                raise UnknownCredentialFormat(
                    f"unsupported Data Integrity cryptosuite {cryptosuite!r}")
            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)

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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
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,
) -> 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()

    fmt = detect_format(credential)
    logger.debug("verify: format=%s", fmt)
    with span("openvc.verify_credential", format=fmt):
        try:
            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)
        except OpenvcError as exc:              # which check failed, no secrets
            logger.info("verify failed: format=%s error=%s", fmt, type(exc).__name__)
            raise
        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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
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