Skip to content

Status lists

Two encodings behind one interface — W3C Bitstring and IETF Token Status List — plus the issuer-side builders.

Checking

openvc.status.status_list

openvc.status.status_list — check a credential's credentialStatus against a Bitstring Status List credential.

Transport- and proof-agnostic by design: the caller injects resolve_status_list — a function that fetches the status-list credential URL and returns the verified status-list VC as a dict (its signature is the caller's concern, since the proof format and the SSRF policy for that host are too). This module then parses the entry, decodes the bitstring, and reads the bit. So it stays pure stdlib and reusable by any VC profile (OB 3.0, EBSI, EUDI).

Supports BitstringStatusListEntry and the older StatusList2021Entry — same field names, same bit encoding.

CredentialRevoked

Bases: OpenvcError

Raised by verifiers that treat a set revocation bit as a hard failure.

Source code in src/openvc/status/status_list.py
35
36
class CredentialRevoked(OpenvcError):
    """Raised by verifiers that treat a set revocation bit as a hard failure."""

CredentialSuspended

Bases: OpenvcError

Raised by verifiers that treat a set suspension bit as a hard failure (a suspended credential is temporarily invalid — not currently usable).

Source code in src/openvc/status/status_list.py
39
40
41
class CredentialSuspended(OpenvcError):
    """Raised by verifiers that treat a set suspension bit as a hard failure
    (a suspended credential is temporarily invalid — not currently usable)."""

parse_status_entries(credential)

Parse the credential's credentialStatus into typed entries. Entries whose type is not a recognised status-list entry are skipped (a credential may carry unrelated status types); a member that is not an object at all (an array, a string reference, null) or an object with no type raises :class:~openvc.status.bitstring.StatusListError instead, since its entries could not be read.

Source code in src/openvc/status/status_list.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def parse_status_entries(credential: dict[str, Any]) -> list[StatusEntry]:
    """Parse the credential's ``credentialStatus`` into typed entries. Entries
    whose type is not a recognised status-list entry are skipped (a credential
    may carry unrelated status types); a member that is not an object at all (an
    array, a string reference, ``null``) or an object with no ``type`` raises
    :class:`~openvc.status.bitstring.StatusListError` instead, since its entries
    could not be read."""
    from .bitstring import StatusListError
    entries: list[StatusEntry] = []
    for raw in _as_entry_list(credential.get("credentialStatus")):
        types = raw.get("type")
        if types is None or types == "" or types == []:
            # VCDM requires a type on every status entry, so an object without one is
            # not an unrelated status type to skip: it is a node reference ({"id": X},
            # which JSON-LD signs exactly as the bare X), and RDF lets the entry it names
            # be described under another property of the credential. Skipping it hid
            # that entry's revocation bit (GHSA-h9rq-wxcw-4rjc), as credentialSchema's
            # "entry needs a type" already prevented.
            raise StatusListError("credentialStatus entry needs a type")
        if isinstance(types, str):
            types = [types]
        elif not isinstance(types, (list, tuple)):
            types = []
        # Only string members can name a status-entry type; filtering them keeps a hostile
        # `type` (a non-iterable, or a list carrying an unhashable/dict member) from crashing
        # the set intersection with a bare TypeError — it is skipped like any unrelated type.
        matched = _ENTRY_TYPES.intersection(t for t in types if isinstance(t, str))
        if not matched:
            continue
        url = raw.get("statusListCredential")
        index = raw.get("statusListIndex")
        if not url or index is None:
            raise StatusListError(
                "status entry needs statusListCredential and statusListIndex")
        # Never str() a hostile value: a deeply nested array overflows the stack with a
        # bare RecursionError, and an int or object would become a garbage URL.
        if not isinstance(url, str):
            raise StatusListError(
                f"statusListCredential must be a URL string, got {brief(url)}")
        purpose = raw.get("statusPurpose", PURPOSE_REVOCATION)
        if not isinstance(purpose, str):   # an explicit null would match no purpose
            raise StatusListError(f"statusPurpose must be a string, got {brief(purpose)}")
        entries.append(StatusEntry(
            status_list_credential=url,
            # the spec encodes the index as a base-10 string; a JSON integer is
            # tolerated, a float / bool / signed or non-ASCII string is not
            index=status_index(index, field="statusListIndex", allow_string=True),
            purpose=purpose,
            entry_type=next(iter(matched)),
        ))
    return entries

check_credential_status(credential, *, resolve_status_list)

Resolve each status-list credential the credential references and read its bit. Returns a :class:StatusResult; never raises on a set bit (that is a verifier policy decision) — only on malformed data or a resolve failure.

Source code in src/openvc/status/status_list.py
171
172
173
174
175
176
177
178
179
180
def check_credential_status(
    credential: dict[str, Any], *, resolve_status_list: ResolveStatusList
) -> StatusResult:
    """Resolve each status-list credential the credential references and read its
    bit. Returns a :class:`StatusResult`; never raises on a *set* bit (that is a
    verifier policy decision) — only on malformed data or a resolve failure."""
    with span("openvc.status"):
        results = [_read_status_bit(resolve_status_list(e.status_list_credential), e)
                   for e in parse_status_entries(credential)]
    return _tally_status(results)

check_credential_status_async(credential, *, resolve_status_list) async

Async :func:check_credential_status — awaits an async resolve_status_list; identical decoding, tally, and fail-closed semantics (the bit-reading is the same pure code).

Source code in src/openvc/status/status_list.py
183
184
185
186
187
188
189
190
191
192
async def check_credential_status_async(
    credential: dict[str, Any], *, resolve_status_list: AsyncResolveStatusList
) -> StatusResult:
    """Async :func:`check_credential_status` — awaits an async ``resolve_status_list``;
    identical decoding, tally, and fail-closed semantics (the bit-reading is the same
    pure code)."""
    with span("openvc.status"):
        results = [_read_status_bit(await resolve_status_list(e.status_list_credential), e)
                   for e in parse_status_entries(credential)]
    return _tally_status(results)

openvc.status.token_status_list

openvc.status.token_status_list — IETF Token Status List (draft-ietf-oauth-status-list-21; IESG-approved and in the RFC Editor queue as of 2026-07, intended Proposed Standard — no RFC number yet). The codec is pinned byte-for-byte to the draft §4.1 examples in tests/test_conformance_status_list.

The second status-list encoding, alongside the W3C Bitstring list in openvc.status.bitstring. The differences that matter:

  • Multi-bit statuses. Each referenced token gets bits in {1, 2, 4, 8}, so a status is a value (0-255, constrained by bits), not just a set/clear bit. 0x00 = VALID, 0x01 = INVALID (revoked), 0x02 = SUSPENDED; the rest are application-specific.
  • LSB-first packing. Index 0 occupies the lowest bits bits of byte 0 (the W3C list is MSB-first); index 1 the next bits up, and so on.
  • DEFLATE/zlib compression (the W3C list uses gzip).

A referenced token points at a status list with a status claim {"status_list": {"idx": N, "uri": "..."}}; the status list token at that URI carries {"status_list": {"bits": B, "lst": "<base64url zlib>"}}. As with the W3C flow, resolving and verifying that token (its proof and the SSRF policy for its host) is the caller's concern — injected as resolve_status_list_token — so this module stays pure stdlib and proof-agnostic.

decode_status_list(lst)

base64url-decode then zlib-inflate a lst value into the packed bytes.

Source code in src/openvc/status/token_status_list.py
60
61
62
63
64
65
66
67
68
69
70
71
def decode_status_list(lst: str) -> bytes:
    """base64url-decode then zlib-inflate a ``lst`` value into the packed bytes."""
    try:
        compressed = base64.urlsafe_b64decode(lst + "=" * (-len(lst) % 4))
    except (ValueError, TypeError) as exc:
        raise StatusListError(f"lst is not valid base64url: {exc}") from exc
    try:
        return inflate_bounded(compressed)
    except zlib.error as exc:
        raise StatusListError(f"lst is not valid zlib/DEFLATE: {exc}") from exc
    except DecompressionBomb as exc:
        raise StatusListError(f"lst decompresses too large: {exc}") from exc

encode_status_list(data)

zlib-deflate then base64url (unpadded) — the inverse of :func:decode_status_list, for issuers and tests. zlib output is deterministic for a fixed level, so no mtime dance is needed.

Source code in src/openvc/status/token_status_list.py
74
75
76
77
78
def encode_status_list(data: bytes) -> str:
    """zlib-deflate then base64url (unpadded) — the inverse of
    :func:`decode_status_list`, for issuers and tests. zlib output is
    deterministic for a fixed level, so no mtime dance is needed."""
    return base64.urlsafe_b64encode(zlib.compress(data, 9)).rstrip(b"=").decode("ascii")

new_status_list(size, *, bits=1)

A zeroed (all-VALID) packed list holding at least size statuses of bits each.

size is an int (never a bool or float), and the packed list may not exceed the 16 MiB openvc decodes: at most :data:~openvc.status._index.MAX_STATUS_INDEX statuses at 1 bit each, an eighth of that at 8 bits. Otherwise :class:StatusListError.

Source code in src/openvc/status/token_status_list.py
81
82
83
84
85
86
87
88
89
90
def new_status_list(size: int, *, bits: int = 1) -> bytearray:
    """A zeroed (all-VALID) packed list holding at least *size* statuses of
    *bits* each.

    *size* is an ``int`` (never a ``bool`` or ``float``), and the packed list may not
    exceed the 16 MiB openvc decodes: at most
    :data:`~openvc.status._index.MAX_STATUS_INDEX` statuses at 1 bit each, an eighth of
    that at 8 bits. Otherwise :class:`StatusListError`."""
    bits = _check_bits(bits)
    return bytearray(packed_length(size, per_byte=8 // bits))

get_status(data, index, *, bits=1)

Return the status value at index for a list packed bits per status (LSB-first). Raises :class:StatusListError for a bad bits, an index that is not an int (a bool or float is refused, not read as some other status) or an out-of-range one.

Source code in src/openvc/status/token_status_list.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def get_status(data: bytes, index: int, *, bits: int = 1) -> int:
    """Return the status value at *index* for a list packed *bits* per status
    (LSB-first). Raises :class:`StatusListError` for a bad *bits*, an *index* that is not
    an ``int`` (a ``bool`` or ``float`` is refused, not read as some other status) or an
    out-of-range one."""
    bits = _check_bits(bits)
    per_byte = 8 // bits
    byte_index, pos = locate_status(
        index, per_byte=per_byte, length=len(data), unit="status")
    mask = (1 << bits) - 1
    return (data[byte_index] >> (pos * bits)) & mask

set_status(data, index, value, *, bits=1)

Set the status value at index in-place (LSB-first). For issuers and tests. index is held to the same rule as :func:get_status and value must be an int (a bool is read as 0 or 1); raises if value does not fit in bits. A bad index type or a negative one is reported first, then value, then an index past the end of data.

Source code in src/openvc/status/token_status_list.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def set_status(data: bytearray, index: int, value: int, *, bits: int = 1) -> None:
    """Set the status value at *index* in-place (LSB-first). For issuers and
    tests. *index* is held to the same rule as :func:`get_status` and *value* must be an
    ``int`` (a ``bool`` is read as 0 or 1); raises if *value* does not fit in *bits*.
    A bad *index* type or a negative one is reported first, then *value*, then an
    *index* past the end of *data*."""
    bits = _check_bits(bits)
    index = status_index(index, field="status index", allow_string=False, capped=False)
    mask = (1 << bits) - 1
    if isinstance(value, int):
        value = plain_int(value)          # as for the index: write the number checked
    if not isinstance(value, int) or not 0 <= value <= mask:
        raise StatusListError(
            f"status value {brief(value)} does not fit in {bits} bit(s)")
    per_byte = 8 // bits
    byte_index, pos = locate_status(
        index, per_byte=per_byte, length=len(data), unit="status")
    shift = pos * bits
    data[byte_index] = (data[byte_index] & ~(mask << shift)) | (value << shift)

parse_token_status_ref(claims)

Parse a referenced token's status.status_list reference, or None if the token carries no status-list reference. Raises on a malformed reference.

Source code in src/openvc/status/token_status_list.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def parse_token_status_ref(claims: dict[str, Any]) -> TokenStatusRef | None:
    """Parse a referenced token's ``status.status_list`` reference, or ``None`` if
    the token carries no status-list reference. Raises on a malformed reference."""
    status = claims.get("status")
    if not isinstance(status, dict):
        return None
    ref = status.get("status_list")
    if not isinstance(ref, dict):
        return None
    uri = ref.get("uri")
    idx = ref.get("idx")
    if not uri or idx is None:
        raise StatusListError("status_list reference needs both uri and idx")
    if not isinstance(uri, str):           # never str() it: deep nesting overflows the stack
        raise StatusListError(f"status_list uri must be a URL string, got {brief(uri)}")
    # the draft makes idx a JSON integer: no string, float or bool form
    return TokenStatusRef(
        uri=uri, index=status_index(idx, field="status_list idx", allow_string=False))

check_token_status(claims, *, resolve_status_list_token)

Resolve the status list token a referenced token points at and read its status.

Returns None if the token carries no status-list reference. Never raises on a non-VALID status — turning INVALID/SUSPENDED into a hard failure is the verifier's policy — only on malformed data or a resolve failure.

Source code in src/openvc/status/token_status_list.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
def check_token_status(
    claims: dict[str, Any], *, resolve_status_list_token: ResolveStatusListToken
) -> TokenStatusResult | None:
    """Resolve the status list token a referenced token points at and read its
    status.

    Returns ``None`` if the token carries no status-list reference. Never raises
    on a non-VALID status — turning INVALID/SUSPENDED into a hard failure is the
    verifier's policy — only on malformed data or a resolve failure.
    """
    ref = parse_token_status_ref(claims)
    if ref is None:
        return None
    with span("openvc.status"):
        token_claims = resolve_status_list_token(ref.uri)
        return _token_status_result(ref, token_claims)

check_token_status_async(claims, *, resolve_status_list_token) async

Async :func:check_token_status — awaits an async resolve_status_list_token; identical decoding and fail-closed semantics (the status-reading is the same pure code).

Source code in src/openvc/status/token_status_list.py
225
226
227
228
229
230
231
232
233
234
235
236
async def check_token_status_async(
    claims: dict[str, Any], *, resolve_status_list_token: AsyncResolveStatusListToken
) -> TokenStatusResult | None:
    """Async :func:`check_token_status` — awaits an async ``resolve_status_list_token``;
    identical decoding and fail-closed semantics (the status-reading is the same
    pure code)."""
    ref = parse_token_status_ref(claims)
    if ref is None:
        return None
    with span("openvc.status"):
        token_claims = await resolve_status_list_token(ref.uri)
        return _token_status_result(ref, token_claims)

Issuing

openvc.status.issue

openvc.status.issue — issuer-side status-list construction.

The check side (:func:~openvc.status.status_list.check_credential_status, :func:~openvc.status.token_status_list.check_token_status) reads a status list; this is the other half — building the artifacts an issuer publishes and updates in order to revoke:

  • :func:build_status_list_credential — a W3C BitstringStatusListCredential (the VC that wraps an encodedList), returned UNSIGNED so the caller secures it with whichever proof suite it already uses (VcJwtProofSuite.sign or DataIntegrityProofSuite.add_proof): the status list is an ordinary VC.
  • :func:build_status_list_token / :func:verify_status_list_token — an IETF status-list token (typ: statuslist+jwt), signed and verified through the shared JOSE path (allow-listed {ES256, ES384, EdDSA, Ed25519}).
  • :func:build_status_list_entry / :func:build_token_status_reference — the tiny pointer an issuer embeds in each issued credential/token so a verifier knows which list bit to read.

A revocation flow: allocate a list (:func:~openvc.status.bitstring.new_bitstring or :func:~openvc.status.token_status_list.new_status_list), set the revoked indices (set_status_bit / set_status), build + sign the artifact, serve it at the URL the credentials point at, and re-issue it when a revocation changes.

The builders hold the pointer and the list to the rules the verifier applies, so openvc never mints one that its own check side then refuses: an index follows :func:~openvc.status._index.status_index (an int, never a bool or float, below MAX_STATUS_INDEX), the pointer strings are non-empty strings, and a list is a bytes, bytearray, memoryview or array.array (wrap any other buffer in memoryview()) of at most the 16 MiB the verifier inflates. Anything else is a :class:~openvc.status.bitstring.StatusListError, raised at build time.

The time inputs and ttl follow the verifier's shape rules too. valid_from and valid_until are a datetime or None; issued_at and expires are a datetime or a finite number of epoch seconds (the NumericDate rule :func:~openvc.proof._verify_common.is_numeric_date, so never a bool, a digit string, NaN or a bignum). A datetime is read the one way every openvc clock check reads it: a naive value is UTC, never the host's local time, and a value with no UTC equivalent is refused. ttl is None or a positive int, never a bool. The ordering of a window (expires before issued_at, a past valid_until) is the verifier's call at read time and is not checked here.

The strings an artefact names are held to what the verifier reads too, so that the proof lane you sign with does not decide whether they mattered. The credential's issuer is a non-empty str, or an object whose id is a non-empty str and which carries no @id (id is the one spelling every lane reads the same; the object's other members are the caller's and are not inspected). subject_id, an entry's id and the token's issuer are None (the default) or a non-empty str: None is the only way to ask for the default (a subject of <id>#list, no entry id, no iss), and an empty or falsy value is an error. contexts is None (the VC 2.0 context) or a non-empty list or tuple whose first entry is a VC base context (https://www.w3.org/ns/credentials/v2 or https://www.w3.org/2018/credentials/v1) and whose other entries are a non-empty str or an object. An identifier's URI shape is not checked: key resolution refuses an issuer it cannot resolve, and only the RDF suites require an IRI. The first-entry rule is a shape rule, the one the RDF suites apply, not a promise that the list verifies on every lane: only the VC 2.0 context defines the terms of a BitstringStatusListCredential and only it is bundled with openvc (see :func:build_status_list_credential). Every string the builders take is copied to an exact str before it is tested, and the copy is what is emitted (:func:~openvc.status._index.plain_str). The members of an issuer or inline context object besides the ones named here are the caller's, with two exceptions, both rules of the VC-JWT lane that reads the list back. None of them, at any depth, may be an SD-JWT marker (a member named _sd, _sd_alg or ...): the lane reads a payload that carries one as an SD-JWT issuer JWT and refuses it (#189, #291). And the object may not nest past MAX_DEPTH - 2 levels (issuer) or MAX_DEPTH - 3 levels (an inline context object), the object itself counting as level 1: the lane decodes the payload through the bounded loader, which refuses nesting past MAX_DEPTH (100, #213) in the whole payload, and the object sits two or three containers inside it. A builder cannot know which lane will secure the list, so such an object is refused at build time on every lane (:func:_refuse_unreadable, which runs the lane's own test on the JSON the object encodes to; eddsa-jcs-2022 would take one more level in each field, and the builder holds it to the VC-JWT limit all the same). The marker as a value ({"name": "..."}) is fine: only member names are read.

This is the only :mod:openvc.status module that touches :mod:openvc.proof (to sign/verify the token); the codecs and the check side stay pure stdlib.

build_status_list_credential(*, id, issuer, bitstring, status_purpose='revocation', valid_from=None, valid_until=None, subject_id=None, ttl=None, contexts=None)

Build an UNSIGNED W3C BitstringStatusListCredential wrapping bitstring.

id is the URL a credential's statusListCredential points at; bitstring is the raw bits (from :func:~openvc.status.bitstring.new_bitstring + set_status_bit). status_purpose must match the entries that reference this list. The result is a plain VC — sign it with any suite before publishing. The inputs are not mutated.

bitstring is a bytes, bytearray, memoryview or array.array (wrap any other buffer in memoryview()); it contributes the bytes it exposes (view.tobytes()), so a non-contiguous memoryview is read as it shows.

valid_from and valid_until are a datetime or None, written as a UTC ...Z string. A naive datetime is UTC, as on every openvc clock check, not the host's local time; an aware one is converted. ttl is None or a positive int (the W3C list gives it in milliseconds).

issuer is a non-empty string (a DID, say) or an object with a non-empty string id and no @id; other members of the object (name) are carried as given, but none, at any depth, may be named _sd, _sd_alg or ... (the SD-JWT markers: the VC-JWT lane refuses a payload that holds one, so the list would be signed and then not read back), and the object may not nest past 98 levels (MAX_DEPTH - 2, the object itself being level 1: the bounded loader that reads the list back stops at 100 levels of the whole payload, and the issuer sits two containers inside it). subject_id is the credentialSubject.id: None gives <id>#list, and an explicit value is a non-empty string. contexts is the @context: None gives ["https://www.w3.org/ns/credentials/v2"], and an explicit value is a non-empty list or tuple that starts with a VC base context, followed by context URLs or inline context objects (whose members are not inspected, except for the same marker names and a nesting past 97 levels, MAX_DEPTH - 3: one that is not JSON fails when the credential is signed). The limits are the VC-JWT lane's; eddsa-jcs-2022 takes one more level in each field, which the builder, not knowing the lane, does not use. Pass the VC 2.0 context first unless you have a reason: it is the one that defines BitstringStatusListCredential, encodedList and statusPurpose, and the only one bundled. The VC 1.1 context is accepted as a base context (the shape the RDF suites accept) and builds; VC-JWT and eddsa-jcs-2022 read no context, while eddsa-rdfc-2022 refuses it with DocumentLoaderError (it is not bundled, and it does not define those terms). A context URL that is not bundled needs extra_contexts on the eddsa-rdfc-2022 suite.

Raises :class:~openvc.status.bitstring.StatusListError for an id or status_purpose that is not a non-empty string, for an issuer that is not a non-empty string or an object whose id is one and which has no @id, for an issuer object or an inline context object that carries an SD-JWT marker member (_sd, _sd_alg, ...) at any depth, that is nested past the loader's cap (more than 98 levels for the issuer, 97 for a context object) or that cannot be read as JSON (a cycle, a key that is no scalar), for a subject_id that is neither None nor a non-empty string ("", 0, [] and {} are errors, not the default), for contexts that is neither None nor a non-empty list / tuple of that shape (a str, a dict, an iterator, an empty list, a first entry that is not a VC base context, an entry that is not a non-empty string or an object), for a bitstring that is not a buffer or is longer than the 16 MiB the verifier inflates, for a valid_from / valid_until that is not a datetime (a string, an epoch number, a date), has a tzinfo that raises or has no UTC equivalent (year 1 at +01:00), and for a ttl that is a bool, a float (NaN included), a string, zero, negative or an integer no float holds.

Source code in src/openvc/status/issue.py
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
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
468
469
470
471
472
473
474
475
476
477
def build_status_list_credential(
    *,
    id: str,
    issuer: str | dict[str, Any],
    bitstring: bytes | bytearray | memoryview | array.array,
    status_purpose: str = "revocation",
    valid_from: datetime | None = None,
    valid_until: datetime | None = None,
    subject_id: str | None = None,
    ttl: int | None = None,
    contexts: list[str | dict[str, Any]] | tuple[str | dict[str, Any], ...] | None = None,
) -> dict[str, Any]:
    """Build an UNSIGNED W3C ``BitstringStatusListCredential`` wrapping *bitstring*.

    *id* is the URL a credential's ``statusListCredential`` points at; *bitstring*
    is the raw bits (from :func:`~openvc.status.bitstring.new_bitstring` +
    ``set_status_bit``). *status_purpose* must match the entries that reference this
    list. The result is a plain VC — **sign it** with any suite before publishing.
    The inputs are not mutated.

    *bitstring* is a ``bytes``, ``bytearray``, ``memoryview`` or ``array.array`` (wrap
    any other buffer in ``memoryview()``); it contributes the bytes it exposes
    (``view.tobytes()``), so a non-contiguous ``memoryview`` is read as it shows.

    *valid_from* and *valid_until* are a ``datetime`` or ``None``, written as a UTC
    ``...Z`` string. A **naive** ``datetime`` is UTC, as on every openvc clock check, not
    the host's local time; an aware one is converted. *ttl* is ``None`` or a positive
    ``int`` (the W3C list gives it in milliseconds).

    *issuer* is a non-empty string (a DID, say) or an object with a non-empty string
    ``id`` and no ``@id``; other members of the object (``name``) are carried as given, but
    none, at any depth, may be named ``_sd``, ``_sd_alg`` or ``...`` (the SD-JWT markers: the
    VC-JWT lane refuses a payload that holds one, so the list would be signed and then not
    read back), and the object may not nest past 98 levels (``MAX_DEPTH - 2``, the object
    itself being level 1: the bounded loader that reads the list back stops at 100 levels
    of the whole payload, and the issuer sits two containers inside it).
    *subject_id* is the ``credentialSubject.id``: ``None`` gives ``<id>#list``, and an explicit
    value is a non-empty string. *contexts* is the ``@context``: ``None`` gives
    ``["https://www.w3.org/ns/credentials/v2"]``, and an explicit value is a non-empty ``list``
    or ``tuple`` that starts with a VC base context, followed by context URLs or inline context
    objects (whose members are not inspected, except for the same marker names and a nesting
    past 97 levels, ``MAX_DEPTH - 3``: one that is not JSON fails when the credential is
    signed). The limits are the VC-JWT lane's; ``eddsa-jcs-2022`` takes one more level in each
    field, which the builder, not knowing the lane, does not use. Pass the VC 2.0 context first
    unless you have a reason: it is the one that defines ``BitstringStatusListCredential``,
    ``encodedList`` and ``statusPurpose``, and the only one bundled. The VC 1.1 context is
    accepted as a base context (the shape the RDF suites accept) and builds; VC-JWT and
    ``eddsa-jcs-2022`` read no context, while ``eddsa-rdfc-2022`` refuses it with
    ``DocumentLoaderError`` (it is not bundled, and it does not define those terms). A context
    URL that is not bundled needs ``extra_contexts`` on the ``eddsa-rdfc-2022`` suite.

    Raises :class:`~openvc.status.bitstring.StatusListError` for an *id* or
    *status_purpose* that is not a non-empty string, for an *issuer* that is not a
    non-empty string or an object whose ``id`` is one and which has no ``@id``, for an
    *issuer* object or an inline context object that carries an SD-JWT marker member (``_sd``,
    ``_sd_alg``, ``...``) at any depth, that is nested past the loader's cap (more than 98
    levels for the issuer, 97 for a context object) or that cannot be read as JSON (a cycle, a
    key that is no scalar), for a *subject_id* that is neither ``None`` nor a non-empty string
    (``""``, ``0``, ``[]`` and ``{}`` are errors, not the default), for *contexts* that is
    neither ``None`` nor a non-empty ``list`` / ``tuple`` of that shape (a ``str``, a ``dict``,
    an iterator, an empty list, a first entry that is not a VC base context, an entry that is
    not a non-empty string or an object), for a *bitstring* that is not a buffer or is longer
    than the 16 MiB the verifier inflates, for a *valid_from* / *valid_until* that is not a
    ``datetime`` (a string, an epoch number, a ``date``), has a ``tzinfo`` that raises or has
    no UTC equivalent (year 1 at ``+01:00``), and for a *ttl* that is a ``bool``, a ``float``
    (``NaN`` included), a string, zero, negative or an integer no float holds.
    """
    id = _text(id, field="status list credential id")
    status_purpose = _text(status_purpose, field="statusPurpose")
    issuer = _credential_issuer(issuer)
    if subject_id is not None:                       # tested with ``is``: "" and 0 are errors
        subject_id = _text(subject_id, field="subject_id")
    context = _contexts(contexts)
    raw_bits = _packed(bitstring, field="bitstring")
    ttl = _ttl(ttl)
    valid_from_iso = _iso(valid_from, name="valid_from") if valid_from is not None else None
    valid_until_iso = (
        _iso(valid_until, name="valid_until") if valid_until is not None else None)
    subject: dict[str, Any] = {
        "id": subject_id if subject_id is not None else f"{id}#list",
        "type": BITSTRING_SUBJECT_TYPE,
        "statusPurpose": status_purpose,
        "encodedList": encode_bitstring(raw_bits),
    }
    if ttl is not None:
        subject["ttl"] = ttl
    credential: dict[str, Any] = {
        "@context": context,
        "id": id,
        "type": ["VerifiableCredential", BITSTRING_CREDENTIAL_TYPE],
        "issuer": issuer,
        "credentialSubject": subject,
    }
    if valid_from_iso is not None:
        credential["validFrom"] = valid_from_iso
    if valid_until_iso is not None:
        credential["validUntil"] = valid_until_iso
    return credential

build_status_list_entry(*, status_list_credential, index, status_purpose='revocation', id=None)

Build a BitstringStatusListEntry for a credential's credentialStatus.

Add the return value to each credential you issue; toggling the same index in the published list (:func:build_status_list_credential) then flips that credential's status. status_purpose must match the list's.

index is an int below :data:~openvc.status._index.MAX_STATUS_INDEX — the verifier's own rule, so it reads back exactly what you wrote; the entry carries it as the base-10 string the spec asks for. A bool, a float (NaN included), a string, a negative or an oversized index, or a status_list_credential / status_purpose that is not a non-empty string, raises :class:~openvc.status.bitstring.StatusListError.

id is the entry's own identifier: None (the default) leaves it out, and an explicit value is a non-empty string ("", 0 and [] raise, they are not "absent"). Nothing reads it back, so it is not checked for a URI shape.

Source code in src/openvc/status/issue.py
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def build_status_list_entry(
    *,
    status_list_credential: str,
    index: int,
    status_purpose: str = "revocation",
    id: str | None = None,
) -> dict[str, Any]:
    """Build a ``BitstringStatusListEntry`` for a credential's ``credentialStatus``.

    Add the return value to each credential you issue; toggling the same *index* in
    the published list (:func:`build_status_list_credential`) then flips that
    credential's status. *status_purpose* must match the list's.

    *index* is an ``int`` below :data:`~openvc.status._index.MAX_STATUS_INDEX` — the
    verifier's own rule, so it reads back exactly what you wrote; the entry carries it
    as the base-10 string the spec asks for. A ``bool``, a ``float`` (``NaN``
    included), a string, a negative or an oversized index, or a *status_list_credential*
    / *status_purpose* that is not a non-empty string, raises
    :class:`~openvc.status.bitstring.StatusListError`.

    *id* is the entry's own identifier: ``None`` (the default) leaves it out, and an
    explicit value is a non-empty string (``""``, ``0`` and ``[]`` raise, they are not
    "absent"). Nothing reads it back, so it is not checked for a URI shape."""
    index = status_index(index, field="statusListIndex", allow_string=False)
    status_list_credential = _text(status_list_credential, field="statusListCredential")
    status_purpose = _text(status_purpose, field="statusPurpose")
    if id is not None:                               # tested with ``is``: "" and 0 are errors
        id = _text(id, field="status list entry id")
    entry: dict[str, Any] = {
        "type": BITSTRING_ENTRY_TYPE,
        "statusPurpose": status_purpose,
        "statusListIndex": str(index),          # the spec encodes the index as a string
        "statusListCredential": status_list_credential,
    }
    if id is not None:
        entry["id"] = id
    return entry

build_status_list_token(*, signing_key, uri, status_list, bits=1, issued_at=None, expires=None, ttl=None, issuer=None)

Build and sign an IETF status-list token (typ: statuslist+jwt).

uri (the sub claim, the list's own URL) is what a referenced token's status.status_list.uri points at; status_list is the packed multi-bit list (from :func:~openvc.status.token_status_list.new_status_list + set_status). Signed via the allow-listed {ES256, ES384, EdDSA, Ed25519} path, so an HSM/Vault key works.

issued_at (iat, default the current time) and expires (exp, default none) take a datetime or a finite number of epoch seconds, such as time.time(), truncated to whole seconds. A naive datetime is UTC, as on every openvc clock check, not the host's local time. ttl is None or a positive int of seconds.

issuer (iss) is None or a non-empty string, which is what :func:~openvc.resolvers.default_status_list_token_resolver needs to find the key. None is allowed and leaves the claim out, but a token without iss is refused by that resolver (StatusListError): pass it unless the caller resolves the key some other way.

status_list is a bytes, bytearray, memoryview or array.array (wrap any other buffer in memoryview()), read as in :func:build_status_list_credential. Raises :class:~openvc.status.bitstring.StatusListError for a uri or an issuer that is not a non-empty string (None aside, for issuer), a bad bits, a status_list that is not a buffer or is longer than the 16 MiB the verifier inflates, an issued_at / expires that is neither a datetime nor a finite number (a bool, a string, a date, NaN, inf, an integer no float holds), a datetime whose tzinfo raises or that has no UTC equivalent (year 1 at +01:00, as for valid_from), and a ttl that is a bool, a float (NaN included), a string, zero, negative or an integer no float holds. Every input is checked before anything is signed.

Source code in src/openvc/status/issue.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
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
def build_status_list_token(
    *,
    signing_key: SigningKey,
    uri: str,
    status_list: bytes | bytearray | memoryview | array.array,
    bits: int = 1,
    issued_at: datetime | int | float | None = None,
    expires: datetime | int | float | None = None,
    ttl: int | None = None,
    issuer: str | None = None,
) -> str:
    """Build and sign an IETF status-list token (``typ: statuslist+jwt``).

    *uri* (the ``sub`` claim, the list's own URL) is what a referenced token's
    ``status.status_list.uri`` points at; *status_list* is the packed multi-bit
    list (from :func:`~openvc.status.token_status_list.new_status_list` +
    ``set_status``). Signed via the allow-listed ``{ES256, ES384, EdDSA, Ed25519}`` path, so an
    HSM/Vault key works.

    *issued_at* (``iat``, default the current time) and *expires* (``exp``, default
    none) take a ``datetime`` or a finite number of epoch seconds, such as
    ``time.time()``, truncated to whole seconds. A **naive** ``datetime`` is UTC, as on
    every openvc clock check, not the host's local time. *ttl* is ``None`` or a positive
    ``int`` of seconds.

    *issuer* (``iss``) is ``None`` or a non-empty string, which is what
    :func:`~openvc.resolvers.default_status_list_token_resolver` needs to find the key.
    ``None`` is allowed and leaves the claim out, but a token without ``iss`` is refused
    by that resolver (``StatusListError``): pass it unless the caller resolves the key
    some other way.

    *status_list* is a ``bytes``, ``bytearray``, ``memoryview`` or ``array.array`` (wrap
    any other buffer in ``memoryview()``), read as in
    :func:`build_status_list_credential`. Raises
    :class:`~openvc.status.bitstring.StatusListError` for a *uri* or an *issuer* that is
    not a non-empty string (``None`` aside, for *issuer*), a bad *bits*, a *status_list*
    that is not a buffer or is longer than the 16 MiB the verifier inflates, an
    *issued_at* / *expires* that is neither a ``datetime`` nor a finite number (a ``bool``,
    a string, a ``date``, ``NaN``, ``inf``, an integer no float holds), a ``datetime``
    whose ``tzinfo`` raises or that has no UTC equivalent (year 1 at ``+01:00``, as for
    *valid_from*), and a *ttl* that is a ``bool``, a ``float`` (``NaN`` included), a
    string, zero, negative or an integer no float holds. Every input is checked before
    anything is signed."""
    uri = _text(uri, field="status list token uri")
    if issuer is not None:                           # tested with ``is``: "" and 0 are errors
        issuer = _text(issuer, field="status list token issuer")
    bits = _check_bits(bits)
    packed = _packed(status_list, field="status_list")
    iat = _epoch(issued_at, name="issued_at") if issued_at is not None else int(time.time())
    exp = _epoch(expires, name="expires") if expires is not None else None
    ttl = _ttl(ttl)
    payload: dict[str, Any] = {
        "sub": uri,
        "iat": iat,
        "status_list": {"bits": bits, "lst": encode_status_list(packed)},
    }
    if issuer is not None:
        payload["iss"] = issuer
    if exp is not None:
        payload["exp"] = exp
    if ttl is not None:
        payload["ttl"] = ttl
    header = {"typ": STATUS_LIST_JWT_TYP, "alg": signing_key.alg, "kid": signing_key.kid}
    return sign_compact(header, payload, signing_key=signing_key)

verify_status_list_token(token, *, public_key_jwk, expected_uri=None, leeway_s=60, now=None)

Verify a status-list token and return its claims.

Allow-lists the algorithm, requires typ: statuslist+jwt, verifies the signature, checks exp / nbf / iat (finite numbers when present, null included; within leeway_s of now), and — when expected_uri is given — that sub equals the URL it was fetched from (the IETF anti-swap check). The returned dict carries the status_list member, so it can be handed straight to :func:~openvc.status.token_status_list.check_token_status as the resolver's result.

leeway_s is a whole number of seconds from 0 to one day (86 400); anything else is a :class:~openvc.status.bitstring.StatusListError, raised before the token is read.

Source code in src/openvc/status/issue.py
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
def verify_status_list_token(
    token: str,
    *,
    public_key_jwk: dict[str, Any],
    expected_uri: str | None = None,
    leeway_s: int = 60,
    now: datetime | None = None,
) -> dict[str, Any]:
    """Verify a status-list token and return its claims.

    Allow-lists the algorithm, requires ``typ: statuslist+jwt``, verifies the
    signature, checks ``exp`` / ``nbf`` / ``iat`` (finite numbers when present, ``null``
    included; within *leeway_s* of *now*), and — when *expected_uri* is
    given — that ``sub`` equals the URL it was fetched from (the IETF anti-swap
    check). The returned dict carries the ``status_list`` member, so it can be
    handed straight to :func:`~openvc.status.token_status_list.check_token_status`
    as the resolver's result.

    *leeway_s* is a whole number of seconds from 0 to one day (86 400); anything else
    is a :class:`~openvc.status.bitstring.StatusListError`, raised before the token
    is read."""
    leeway_seconds(leeway_s, error=StatusListError)
    header, claims = verify_compact(token, public_key_jwk=public_key_jwk)
    if header.get("typ") != STATUS_LIST_JWT_TYP:
        raise StatusListError(
            f"unexpected token typ {brief(header.get('typ'))}, want {STATUS_LIST_JWT_TYP!r}")
    if expected_uri is not None and claims.get("sub") != expected_uri:
        raise StatusListError(
            f"status list token sub {brief(claims.get('sub'))} != expected {expected_uri!r}")
    # The shared JOSE rule (ADR-0008): a present exp/nbf/iat, null included, must be a
    # finite JSON number; NaN / Infinity / 1e400 would make every comparison False, so
    # the list would never go stale. exp, nbf and a future iat are all judged at one
    # instant: *now* when pinned, else the wall clock (the resolvers never pin it). A
    # pin earlier than the list's iat/nbf therefore rejects it: the list did not exist
    # at that instant.
    for name in ("exp", "nbf", "iat"):        # shape first, naming what came instead
        if name in claims and not is_numeric_date(claims[name]):
            raise StatusListError(
                f"status list token {name} must be a finite numeric timestamp, "
                f"got {brief(claims[name])}")
    if now is None:
        instant = None
    elif isinstance(now, int) and not isinstance(now, bool):
        instant = now                             # epoch seconds, as _epoch accepted
    else:                                         # a datetime; anything else is typed
        instant = utc_epoch(now, error=StatusListError)
    try:
        check_jwt_temporal(claims, leeway_s=leeway_s,
                           subject="status list token", now=instant)
    except ClaimsInvalid as exc:
        raise StatusListError(str(exc)) from exc
    return claims

build_token_status_reference(*, uri, index)

Build the status claim a referenced token carries to point at an IETF status-list token — merge it into the token's claims when you issue it.

index follows the same rule as :func:build_status_list_entry, and the IETF draft makes it a JSON integer, so a string is refused too; uri must be a non-empty string. :class:~openvc.status.bitstring.StatusListError otherwise.

Source code in src/openvc/status/issue.py
643
644
645
646
647
648
649
650
651
652
def build_token_status_reference(*, uri: str, index: int) -> dict[str, Any]:
    """Build the ``status`` claim a referenced token carries to point at an IETF
    status-list token — merge it into the token's claims when you issue it.

    *index* follows the same rule as :func:`build_status_list_entry`, and the IETF
    draft makes it a JSON integer, so a string is refused too; *uri* must be a non-empty
    string. :class:`~openvc.status.bitstring.StatusListError` otherwise."""
    idx = status_index(index, field="status_list idx", allow_string=False)
    uri = _text(uri, field="status_list uri")
    return {"status": {"status_list": {"idx": idx, "uri": uri}}}

Resolver factories

The batteries-included status-list resolvers the pipeline accepts (and their async twins), which fetch and verify a referenced status list before reading its bits.

openvc.resolvers

openvc.resolvers — blessed, SSRF-guarded default resolvers for the status and schema fetch paths.

:func:~openvc.verify.verify_credential's resolve_status_list, resolve_status_list_token and resolve_credential_schema are caller-injected: the transport, the SSRF policy for each issuer-named host, and — for status — the proof verification of the fetched list are the caller's concern, so openvc.fetch's guard protects these URLs only if you pass a guarded fetch. That makes the secure path the opt-in path.

These factories are the safe drop-in that makes the secure path the easy path: they fetch through the SSRF-guarded https fetch (openvc.fetch: https-only, private/loopback/link-local blocked, redirects refused, connection pinned to the validated IP) and — for status — verify the fetched status list through the pipeline before trusting it. Pass one of these to verify_credential; a custom resolver deliberately opts out of the guard.

from openvc import verify_credential
from openvc.resolvers import (default_status_list_resolver,
                              default_credential_schema_resolver)

verify_credential(
    cred, resolver=reg,
    resolve_status_list=default_status_list_resolver(resolver=reg),
    resolve_credential_schema=default_credential_schema_resolver())

Caching for high-volume verification. A default status resolver fetches and re-verifies the status list on every call — a batch of N credentials from one issuer would otherwise download and verify one shared list N times. :func:openvc.verify.verify_many (and its async twin) already deduplicate this within a call via :func:openvc.cache.batch_resolvers (each distinct issuer / status list resolved once). For repeated single verify_credential calls in a long-lived verifier, wrap your resolvers with :func:openvc.cache.cached_resolve (a short-TTL memo) and your DID resolver with :class:openvc.cache.CachingDidResolver, so a hot status list / DID document is not refetched per credential.

default_credential_schema_resolver(*, fetch=https_bytes_fetch)

A resolve_credential_schema that fetches the raw schema bytes over the SSRF-guarded https fetch. Returning bytes lets the pipeline verify a credentialSchema.digestSRI over the exact response before parsing.

Source code in src/openvc/resolvers.py
62
63
64
65
66
67
68
69
70
def default_credential_schema_resolver(
    *, fetch: Any = https_bytes_fetch,
) -> ResolveCredentialSchema:
    """A ``resolve_credential_schema`` that fetches the raw schema bytes over the
    SSRF-guarded https fetch. Returning bytes lets the pipeline verify a
    ``credentialSchema.digestSRI`` over the exact response before parsing."""
    def resolve(url: str) -> bytes:
        return fetch(url)
    return resolve

default_type_metadata_resolver(*, fetch=https_bytes_fetch)

A resolve for :func:openvc.type_metadata.validate_type_metadata that fetches the raw Type Metadata bytes from a vct / extends URL over the SSRF-guarded https fetch. Returning the exact bytes lets vct#integrity / extends#integrity be verified over the response before parsing.

Source code in src/openvc/resolvers.py
73
74
75
76
77
78
79
80
81
82
def default_type_metadata_resolver(
    *, fetch: Any = https_bytes_fetch,
) -> ResolveTypeMetadata:
    """A ``resolve`` for :func:`openvc.type_metadata.validate_type_metadata` that
    fetches the raw Type Metadata bytes from a ``vct`` / ``extends`` URL over the
    SSRF-guarded https fetch. Returning the exact bytes lets ``vct#integrity`` /
    ``extends#integrity`` be verified over the response before parsing."""
    def resolve(url: str) -> bytes:
        return fetch(url)
    return resolve

default_status_list_resolver(*, resolver=None, jwt_vc_issuer_fetch=None, leeway_s=60, extra_contexts=None, fetch=https_text_fetch, expected_issuer=None)

A resolve_status_list (W3C Bitstring) that fetches the status-list credential over the SSRF-guarded https fetch and verifies it through the pipeline before returning it — a fetched-but-unverified status list would let a forged one clear revocation. Status-of-status recursion is turned off (require_status=False); resolver resolves the status issuer's key.

The returned body names its issuer: a list whose body names a different one than its signer is refused (ClaimsInvalid on a VC-JWT or vc+sd-jwt list, where the pipeline reconciles iss and issuer first; StatusListError on a dc+sd-jwt list, where it does not), and one whose body names no one (the issuer is in the JWT iss only) gets the verified signer filled in, on a copy, so require_status_issuer_binding can bind it (ADR-0006, #226).

Source code in src/openvc/resolvers.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def default_status_list_resolver(
    *, resolver: Any = None, jwt_vc_issuer_fetch: Any = None,
    leeway_s: int = 60, extra_contexts: Any = None, fetch: Any = https_text_fetch,
    expected_issuer: str | None = None,
) -> ResolveStatusList:
    """A ``resolve_status_list`` (W3C Bitstring) that fetches the status-list
    credential over the SSRF-guarded https fetch and **verifies** it through the
    pipeline before returning it — a fetched-but-unverified status list would let a
    forged one clear revocation. Status-of-status recursion is turned off
    (``require_status=False``); *resolver* resolves the status issuer's key.

    The returned body names its issuer: a list whose body names a different one than its
    signer is refused (``ClaimsInvalid`` on a VC-JWT or ``vc+sd-jwt`` list, where the
    pipeline reconciles ``iss`` and ``issuer`` first; ``StatusListError`` on a
    ``dc+sd-jwt`` list, where it does not), and one whose body names no one (the issuer is
    in the JWT ``iss`` only) gets the verified signer filled in, on a copy, so
    ``require_status_issuer_binding`` can bind it (ADR-0006, #226)."""
    leeway_s = leeway_seconds(leeway_s, error=StatusListError)

    def resolve(url: str) -> dict:
        from .verify import VerificationPolicy, verify_credential
        credential = _as_credential(fetch(url))
        _require_expected_issuer(_claimed_list_issuer(credential), expected_issuer)
        result = verify_credential(
            credential, resolver=resolver, jwt_vc_issuer_fetch=jwt_vc_issuer_fetch,
            policy=VerificationPolicy(require_status=False, leeway_s=leeway_s),
            extra_contexts=extra_contexts)
        return _authenticated_list(result)
    return resolve

default_status_list_token_resolver(*, resolver=None, jwt_vc_issuer_fetch=None, leeway_s=60, fetch=https_text_fetch, expected_issuer=None)

A resolve_status_list_token (IETF) that fetches the statuslist+jwt token over the SSRF-guarded https fetch, resolves the issuer key, verifies the token (typ + signature + exp + sub == the fetched URI, the IETF anti-swap check), and returns its claims.

Source code in src/openvc/resolvers.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def default_status_list_token_resolver(
    *, resolver: Any = None, jwt_vc_issuer_fetch: Any = None,
    leeway_s: int = 60, fetch: Any = https_text_fetch,
    expected_issuer: str | None = None,
) -> ResolveStatusListToken:
    """A ``resolve_status_list_token`` (IETF) that fetches the ``statuslist+jwt``
    token over the SSRF-guarded https fetch, resolves the issuer key, verifies the
    token (typ + signature + ``exp`` + ``sub`` == the fetched URI, the IETF
    anti-swap check), and returns its claims."""
    leeway_s = leeway_seconds(leeway_s, error=StatusListError)

    def resolve(uri: str) -> dict:
        from .proof._jws import parse_compact
        from .status import StatusListError, verify_status_list_token
        from .verify import _resolve_jose_key, default_resolver
        token = fetch(uri).strip()
        header, payload, _, _ = parse_compact(token)
        iss, kid = payload.get("iss"), header.get("kid")
        if not isinstance(iss, str) or not iss:
            raise StatusListError(
                "status list token has no string `iss` to resolve its key")
        _require_expected_issuer(iss, expected_issuer)
        reg = resolver if resolver is not None else default_resolver()
        jwk = _resolve_jose_key(reg, iss, kid, jwt_vc_issuer_fetch)
        return verify_status_list_token(
            token, public_key_jwk=jwk, expected_uri=uri, leeway_s=leeway_s)
    return resolve

default_credential_schema_resolver_async(*, fetch=https_bytes_fetch_async)

Async :func:default_credential_schema_resolver — fetches the raw schema bytes over the SSRF-guarded async https fetch.

Source code in src/openvc/resolvers.py
248
249
250
251
252
253
254
255
def default_credential_schema_resolver_async(
    *, fetch: Any = https_bytes_fetch_async,
) -> AsyncResolveCredentialSchema:
    """Async :func:`default_credential_schema_resolver` — fetches the raw schema
    bytes over the SSRF-guarded async https fetch."""
    async def resolve(url: str) -> bytes:
        return await fetch(url)
    return resolve

default_status_list_resolver_async(*, resolver=None, jwt_vc_issuer_fetch=None, leeway_s=60, extra_contexts=None, fetch=https_text_fetch_async, expected_issuer=None)

Async :func:default_status_list_resolver — fetches the status-list credential over the SSRF-guarded async fetch and verifies it through the async pipeline before returning it. resolver is an :class:AsyncDidResolver. The returned body names its issuer exactly as :func:default_status_list_resolver's does.

Source code in src/openvc/resolvers.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def default_status_list_resolver_async(
    *, resolver: Any = None, jwt_vc_issuer_fetch: Any = None,
    leeway_s: int = 60, extra_contexts: Any = None, fetch: Any = https_text_fetch_async,
    expected_issuer: str | None = None,
) -> AsyncResolveStatusList:
    """Async :func:`default_status_list_resolver` — fetches the status-list credential
    over the SSRF-guarded async fetch and **verifies** it through the async pipeline
    before returning it. *resolver* is an :class:`AsyncDidResolver`. The returned body
    names its issuer exactly as :func:`default_status_list_resolver`'s does."""
    leeway_s = leeway_seconds(leeway_s, error=StatusListError)

    async def resolve(url: str) -> dict:
        from .aio import verify_credential_async
        from .verify import VerificationPolicy
        credential = _as_credential(await fetch(url))
        _require_expected_issuer(_claimed_list_issuer(credential), expected_issuer)
        result = await verify_credential_async(
            credential, resolver=resolver, jwt_vc_issuer_fetch=jwt_vc_issuer_fetch,
            policy=VerificationPolicy(require_status=False, leeway_s=leeway_s),
            extra_contexts=extra_contexts)
        return _authenticated_list(result)
    return resolve

default_status_list_token_resolver_async(*, resolver=None, jwt_vc_issuer_fetch=None, leeway_s=60, fetch=https_text_fetch_async, expected_issuer=None)

Async :func:default_status_list_token_resolver — fetches the statuslist+jwt over the SSRF-guarded async fetch, resolves the issuer key via the async pipeline, and verifies the token (typ + signature + exp + sub == the fetched URI).

Source code in src/openvc/resolvers.py
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
def default_status_list_token_resolver_async(
    *, resolver: Any = None, jwt_vc_issuer_fetch: Any = None,
    leeway_s: int = 60, fetch: Any = https_text_fetch_async,
    expected_issuer: str | None = None,
) -> AsyncResolveStatusListToken:
    """Async :func:`default_status_list_token_resolver` — fetches the ``statuslist+jwt``
    over the SSRF-guarded async fetch, resolves the issuer key via the async pipeline,
    and verifies the token (typ + signature + ``exp`` + ``sub`` == the fetched URI)."""
    leeway_s = leeway_seconds(leeway_s, error=StatusListError)

    async def resolve(uri: str) -> dict:
        from .aio import _resolve_jose_key_async, default_async_resolver
        from .proof._jws import parse_compact
        from .status import StatusListError, verify_status_list_token
        token = (await fetch(uri)).strip()
        header, payload, _, _ = parse_compact(token)
        iss, kid = payload.get("iss"), header.get("kid")
        if not isinstance(iss, str) or not iss:
            raise StatusListError(
                "status list token has no string `iss` to resolve its key")
        _require_expected_issuer(iss, expected_issuer)
        reg = resolver if resolver is not None else default_async_resolver()
        jwk = await _resolve_jose_key_async(reg, iss, kid, jwt_vc_issuer_fetch)
        return verify_status_list_token(
            token, public_key_jwk=jwk, expected_uri=uri, leeway_s=leeway_s)
    return resolve