diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a688940 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# The bundled JSON-LD contexts are integrity-pinned: openbadgeslib/ob3/contexts/ +# __init__.py records a SHA-256 per file, and that digest is the control which +# makes the fail-closed context allowlist meaningful (the bundled document +# decides what a Data Integrity proof covers). Git's EOL conversion would +# rewrite them to CRLF on a Windows checkout, so the bytes on disk would stop +# being the bytes that were hashed and the provenance could not be checked at +# all — which is exactly what the gate exists to prevent (#266, #274). +# +# `-text` disables EOL conversion for these files on every platform. Treat any +# other byte-pinned artifact the same way. +openbadgeslib/ob3/contexts/*.json -text diff --git a/Changelog.txt b/Changelog.txt index 304a09e..7b076e1 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -8,6 +8,61 @@ Newest first. Dates are ISO 8601 (YYYY-MM-DD). A security & correctness hardening pass (audit-bot). + - fix(build): stop git's EOL conversion from defeating the bundled contexts' + recorded SHA-256 provenance on Windows. Without a `.gitattributes` entry git + checks the JSON-LD contexts out with CRLF on a Windows clone, so the bytes on + disk are not the bytes the digests in ob3/contexts/__init__.py cover — the + control that makes the fail-closed context allowlist meaningful could not be + evaluated on that platform at all, and the `test (windows)` CI leg had been + red since the gate landed (which also blocks releases: `publish` is + `needs: test`). The contexts are now marked `-text`, and a gate asserts that + attribute stays. Wheel/sdist installs were never affected (#274). + - fix(keys): a failed PEM load lands in the library's exception taxonomy on + every supported `cryptography`, and the floor moves to >=45. cryptography + v47 changed the contract — loading a key of an unsupported algorithm now + raises `UnsupportedAlgorithm`, which is NOT a ValueError — while + `KeyRSA`/`KeyECC.read_private_key`/`read_public_key` and the Ed25519 loaders + caught nothing at all, so a corrupt or unsupported key PEM escaped as a raw + crypto traceback (the `Badge` entry point was already guarded; these direct + callers were not). All PEM loading now goes through one guarded helper that + maps both contracts onto `PrivateKeyReadError` / `PublicKeyReadError`, + keeping the original exception as `__cause__`; `detect_key_type` / + `ec_curve_from_pem` stay total. `KeyRSA.generate_keypair` maps the v48 + 1024-bit floor to `GenPrivateKeyError` instead of a raw ValueError. The + floor is 45 (openvc-core's own pin, so the extras resolve one range) rather + than 47: both contracts are handled and tested, so requiring an April-2026 + release would buy nothing (#244). + - feat(eudi): build the OpenID4VCI credential configuration entry for an Open + Badge issued as SD-JWT VC. `badge_credential_configuration()` returns the + `credential_configurations_supported` entry a wallet reads to request the + badge — `format: dc+sd-jwt`, the `vct`, signing/proof algorithms (ES256, the + HAIP floor), `cryptographic_binding_methods_supported`, `claims` and + `display` — derived from the same `badge_type_metadata()` document the + issuer serves at that `vct`, so the advertised claim set and the served Type + Metadata cannot drift apart (the drift makes a wallet reject the credential + or display nothing). Pure-Python, no `[eudi]` extra. The enclosing + `/.well-known/openid-credential-issuer` document, the Credential Offer and + the endpoints stay the application's: they are deployment policy, one per + tenant (#271). + - feat(eudi): SD-JWT VC badges are revocable, via the IETF OAuth Token Status + List. `issue_badge_sd_jwt(..., status=...)` takes the reference + `openvc.status.issue.build_token_status_reference(uri=…, index=…)` builds and + emits it as a `status` claim that is **always disclosed** — never selectively + disclosable, whatever `disclosable=` says, since a holder able to withhold + the pointer could present a revoked badge as an unrevokable one. On the + verify side both trust paths (pinned key and x5c) now take + `require_status` / `resolve_status_list_token`: a badge whose bit says + revoked or suspended raises `EudiError`, and every way of not knowing the + status fails closed — an unresolvable, wrongly-signed, expired or + URL-swapped list raises rather than reading as "not revoked". The new + `status_list_token_resolver(pubkey_pem=…)` builds the safe default resolver + (HTTPS fetch + openvc-core's `verify_status_list_token`, including the IETF + anti-swap `sub` check). Encoding, signing and packing stay openvc-core's; + the `[eudi]` floor moves to >=1.23. This closes the #226 irrevocability + boundary for this track. Two formats, two lists: a credential carrying a + W3C `credentialStatus` (Bitstring) is still refused at SD-JWT issuance + rather than silently mapped onto a different wire format — a badge issued + on both tracks needs an index in each list (#245, #270). - security(ob2): make the fetched copy of a HostedBadge authoritative for the whole document. `_verify_hosted` reconciled only `id`, `recipient`, `badge`, `issuedOn` and `expires` and then kept the copy baked into the image, so diff --git a/environment.yml b/environment.yml index 02b5d34..8e3ac85 100644 --- a/environment.yml +++ b/environment.yml @@ -16,7 +16,7 @@ dependencies: # input, so CVE-2026-32597 (crit validation) and CVE-2026-48525 (b64=false # payload DoS) are on a reachable path. - pyjwt>=2.13 - - cryptography>=42 # required by pyjwt[crypto] + - cryptography>=45 # required by pyjwt[crypto]; openvc-core's core pin too - defusedxml>=0.7 # hardened XML parsing of untrusted SVG badges # Development / testing diff --git a/openbadgeslib/keys.py b/openbadgeslib/keys.py index 2bbab33..5aa4936 100644 --- a/openbadgeslib/keys.py +++ b/openbadgeslib/keys.py @@ -29,7 +29,9 @@ # ``key_to_pem`` now takes only ``cryptography`` key objects and PEM bytes/str. from typing import Any, Optional, Tuple, Union, cast -from .errors import PublicKeyReadError, UnknownKeyType +from .errors import ( + GenPrivateKeyError, PrivateKeyReadError, PublicKeyReadError, UnknownKeyType) +from cryptography.exceptions import UnsupportedAlgorithm from cryptography.hazmat.primitives import serialization as _crypto_serialization from cryptography.hazmat.primitives.asymmetric import ec, rsa from cryptography.hazmat.primitives.asymmetric.ed25519 import ( @@ -38,6 +40,32 @@ import logging logger = logging.getLogger(__name__) +# What a failed PEM load raises depends on the `cryptography` version: up to v46 +# an unsupported key algorithm came back as ValueError; **v47 changed the +# contract** to UnsupportedAlgorithm (which is NOT a ValueError). Our floor +# spans that change, so both are caught in one place and mapped onto the +# library's own taxonomy — a caller must never have to know which cryptography +# it resolved, and a raw crypto exception must never escape as a traceback. +_PEM_LOAD_ERRORS = (ValueError, TypeError, UnsupportedAlgorithm) + + +def _load_private_pem(data: bytes) -> Any: + """Load a private key PEM, mapping any load failure to PrivateKeyReadError.""" + try: + return _crypto_serialization.load_pem_private_key(data, password=None) + except _PEM_LOAD_ERRORS as exc: + raise PrivateKeyReadError( + 'Unable to read private key PEM: %s' % exc) from exc + + +def _load_public_pem(data: bytes) -> Any: + """Load a public key PEM, mapping any load failure to PublicKeyReadError.""" + try: + return _crypto_serialization.load_pem_public_key(data) + except _PEM_LOAD_ERRORS as exc: + raise PublicKeyReadError( + 'Unable to read public key PEM: %s' % exc) from exc + class KeyType(Enum): RSA = 'RSA 2048' @@ -100,20 +128,26 @@ def __init__(self, key_size: int = 2048) -> None: def generate_keypair(self) -> Tuple[bytes, bytes]: """ Generate a RSA Key, returning in PEM Format """ - self.priv_key = rsa.generate_private_key( - public_exponent=65537, key_size=self._key_size) + try: + self.priv_key = rsa.generate_private_key( + public_exponent=65537, key_size=self._key_size) + except (ValueError, UnsupportedAlgorithm) as exc: + # cryptography v48 enforces a 1024-bit floor on key_size; below it + # (and on any other refused parameter) this must be a library error, + # not a raw traceback out of the CLI. + raise GenPrivateKeyError( + 'Unable to generate a %d-bit RSA key: %s' + % (self._key_size, exc)) from exc self.pub_key = self.priv_key.public_key() return self.get_priv_key_pem(), self.get_pub_key_pem() def read_private_key(self, key_pem: Any = None) -> None: """ Read the private key from param in PEM format """ - self.priv_key = _crypto_serialization.load_pem_private_key( - _pem_bytes(key_pem), password=None) + self.priv_key = _load_private_pem(_pem_bytes(key_pem)) def read_public_key(self, key_pem: Any = None) -> None: """ Read the public key from param in PEM format """ - self.pub_key = _crypto_serialization.load_pem_public_key( - _pem_bytes(key_pem)) + self.pub_key = _load_public_pem(_pem_bytes(key_pem)) def get_priv_key_pem(self) -> bytes: return _private_pem(self.priv_key) @@ -137,13 +171,11 @@ def generate_keypair(self) -> Tuple[bytes, bytes]: def read_private_key(self, key_pem: Any = None) -> None: """ Read the private key from param in PEM format """ - self.priv_key = _crypto_serialization.load_pem_private_key( - _pem_bytes(key_pem), password=None) + self.priv_key = _load_private_pem(_pem_bytes(key_pem)) def read_public_key(self, key_pem: Any = None) -> None: """ Read the public key from param in PEM format """ - self.pub_key = _crypto_serialization.load_pem_public_key( - _pem_bytes(key_pem)) + self.pub_key = _load_public_pem(_pem_bytes(key_pem)) def get_priv_key_pem(self) -> bytes: return _private_pem(self.priv_key) @@ -155,7 +187,7 @@ def get_pub_key_pem(self) -> bytes: def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PrivateKey: if key_pem is None: raise UnknownKeyType('No Ed25519 private key PEM provided') - key = _crypto_serialization.load_pem_private_key(_pem_bytes(key_pem), password=None) + key = _load_private_pem(_pem_bytes(key_pem)) if not isinstance(key, Ed25519PrivateKey): raise UnknownKeyType('PEM is not an Ed25519 private key') return key @@ -164,7 +196,7 @@ def _load_ed25519_private_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519Pr def _load_ed25519_public_key(key_pem: Optional[Union[str, bytes]]) -> Ed25519PublicKey: if key_pem is None: raise UnknownKeyType('No Ed25519 public key PEM provided') - key = _crypto_serialization.load_pem_public_key(_pem_bytes(key_pem)) + key = _load_public_pem(_pem_bytes(key_pem)) if not isinstance(key, Ed25519PublicKey): raise UnknownKeyType('PEM is not an Ed25519 public key') return key @@ -249,7 +281,7 @@ def public_jwk_from_pem(pubkey_pem: Union[str, bytes]) -> dict[str, Any]: import json from jwt.algorithms import ECAlgorithm, OKPAlgorithm, RSAAlgorithm try: - pub = _crypto_serialization.load_pem_public_key(_pem_bytes(pubkey_pem)) + pub = _load_public_pem(_pem_bytes(pubkey_pem)) if isinstance(pub, rsa.RSAPublicKey): jwk_json = RSAAlgorithm.to_jwk(pub) elif isinstance(pub, ec.EllipticCurvePublicKey): @@ -277,12 +309,9 @@ def ec_curve_from_pem(pem_data: Union[str, bytes]) -> Optional[str]: fixes the JOSE algorithm. """ data = _pem_bytes(pem_data) - for load in ( - lambda: _crypto_serialization.load_pem_public_key(data), - lambda: _crypto_serialization.load_pem_private_key(data, password=None), - ): + for load in (_load_public_pem, _load_private_pem): try: - key = load() + key = load(data) except Exception: continue if isinstance(key, (ec.EllipticCurvePublicKey, @@ -302,12 +331,9 @@ def detect_key_type(pem_data: Union[str, bytes]) -> 'KeyType': """ data = _pem_bytes(pem_data) key: Any = None - for load in ( - lambda: _crypto_serialization.load_pem_public_key(data), - lambda: _crypto_serialization.load_pem_private_key(data, password=None), - ): + for load in (_load_public_pem, _load_private_pem): try: - key = load() + key = load(data) break except Exception: continue diff --git a/openbadgeslib/ob3/eudi.py b/openbadgeslib/ob3/eudi.py index aa126a1..42bd43e 100644 --- a/openbadgeslib/ob3/eudi.py +++ b/openbadgeslib/ob3/eudi.py @@ -33,21 +33,29 @@ # Requires the optional ``[eudi]`` extra (``pip install openbadgeslib[eudi]``), # which pulls ``openvc-core``. SD-JWT allows only Ed25519 (EdDSA) and the NIST # curves P-256 (ES256) / P-384 (ES384) — RSA is not in its algorithm set. +# +# Revocation on this track is the **IETF OAuth Token Status List** (the JOSE +# status format SD-JWT VC uses), not the W3C Bitstring Status List the OB 3.0 +# JWT-VC / Data Integrity tracks use — see ``issue_badge_sd_jwt(status=…)``. import base64 import hashlib import json -from typing import Any, Iterable, Mapping, Optional, cast +from typing import Any, Callable, Iterable, Mapping, Optional, Union, cast from .credential import OpenBadgeCredential from ..errors import LibOpenBadgesException from ..keys import ( KeyType, detect_key_type, ec_curve_from_pem, public_jwk_from_pem) +from ..util import download_file # A vct (Verifiable Credential Type) for Open Badges expressed as SD-JWT VC. OB3_SD_JWT_VCT = "https://purl.imsglobal.org/spec/ob/v3p0#OpenBadgeCredential" +# The OpenID4VCI / DCQL format identifier for an IETF SD-JWT VC credential. +OB3_SD_JWT_FORMAT = "dc+sd-jwt" + # Claims made selectively disclosable by default: the recipient's identity, so a # holder can present the achievement while withholding who they are. DEFAULT_DISCLOSABLE = ("credentialSubject",) @@ -71,6 +79,22 @@ def _require_openvc() -> Any: return Ed25519SigningKey, P256SigningKey, P384SigningKey, SdJwtVcProofSuite +def _require_openvc_status() -> Any: + """Import openvc-core's IETF Token Status List pieces, or raise with a hint. + + Needs openvc-core >=1.22, where the status-list codec grew the IETF token + flavour alongside the W3C Bitstring one. + """ + try: + from openvc.status.token_status_list import ( + check_token_status, parse_token_status_ref) + except ImportError as exc: + raise EudiError( + "%s (>=1.22 for IETF Token Status List support)" % _INSTALL_HINT + ) from exc + return check_token_status, parse_token_status_ref + + def _signing_key(privkey_pem: Any, kid: str) -> Any: """Build the openvc SigningKey matching the PEM's key type / curve. @@ -105,10 +129,12 @@ def badge_to_sd_jwt_claims(credential: OpenBadgeCredential) -> dict[str, Any]: (``DEFAULT_DISCLOSABLE`` covers the whole ``credentialSubject``). ``iss``/``vct``/``iat`` are set by the suite at issuance. - ``credentialStatus`` is NOT mapped: SD-JWT VC status carriage/checking is not - wired yet, so an SD-JWT VC badge is currently irrevocable. A credential that - carries a status is rejected at issuance by :func:`issue_badge_sd_jwt` rather - than silently dropped here. + The credential's W3C ``credentialStatus`` is NOT mapped: this track revokes + through the **IETF Token Status List**, a different wire format pointing at a + different list (see :func:`issue_badge_sd_jwt`, which takes that reference as + ``status=`` and emits it as an always-disclosed ``status`` claim). A + credential carrying a W3C entry is still rejected at issuance rather than + silently dropped here. """ vc = credential.to_vc() subject = vc.get("credentialSubject", {}) @@ -196,6 +222,141 @@ def type_metadata_integrity(document: Mapping[str, Any]) -> str: return "sha256-" + base64.b64encode(digest).decode("ascii") +def _status_claim(status: Mapping[str, Any]) -> dict[str, Any]: + """Normalize and validate an IETF Token Status List reference. + + Accepts either the wrapper ``openvc.status.issue.build_token_status_reference`` + returns (``{"status": {"status_list": {"uri": …, "idx": …}}}``) or the inner + ``{"status_list": …}`` object, and returns the wrapper form. The reference is + parsed by openvc-core's ``parse_token_status_ref``, so a malformed one + (missing ``uri``/``idx``, a negative or non-integer index) is rejected **here** + rather than producing a badge whose status can never be read. + """ + if not isinstance(status, Mapping): + raise EudiError( + "status must be the mapping build_token_status_reference() returns, " + "got %s" % type(status).__name__) + inner = status["status"] if "status" in status else status + if not isinstance(inner, Mapping): + raise EudiError( + "status.status must be a mapping, got %s" % type(inner).__name__) + claim: dict[str, Any] = dict(inner) + _, parse_token_status_ref = _require_openvc_status() + try: + ref = parse_token_status_ref({"status": claim}) + except Exception as exc: + raise EudiError("invalid status reference: %s" % exc) from exc + if ref is None: + raise EudiError( + "status carries no status_list reference; build it with " + "openvc.status.issue.build_token_status_reference(uri=…, index=…)") + return claim + + +# ── OpenID4VCI credential configuration (Credential Issuer Metadata) ───────── +# An OpenID4VCI issuer advertises what it can issue in +# ``credential_configurations_supported``, and a wallet picks one by +# ``credential_configuration_id``. The *content* of the Open Badge entry is Open +# Badges knowledge — the vct, the claim set, which claims are disclosable — so it +# is built here, from the same constants issuance uses, instead of hand-written +# once per deployment. The *document* that embeds it +# (``/.well-known/openid-credential-issuer``: credential_issuer, endpoints, +# authorization servers, per-tenant display) is deployment policy and stays the +# application's; so are the Credential Offer, the Credential Response and the +# nonce store. Pure-Python — no ``[eudi]`` extra needed. + +# Human labels for the badge claim paths, so a wallet has something to show. +_CLAIM_DISPLAY_NAMES: dict[tuple[str, ...], str] = { + ("name",): "Credential name", + ("achievement",): "Achievement", + ("achievement", "name"): "Achievement name", + ("validFrom",): "Valid from", + ("validUntil",): "Valid until", + ("credentialSubject",): "Recipient", +} + + +def badge_credential_configuration( + *, + vct: str = OB3_SD_JWT_VCT, + signing_alg_values: Iterable[str] = ("ES256",), + proof_signing_alg_values: Optional[Iterable[str]] = None, + cryptographic_binding_methods: Iterable[str] = ("jwk",), + display: Optional[Iterable[Mapping[str, Any]]] = None, + scope: Optional[str] = None, + locale: str = "en", +) -> dict[str, Any]: + """Build the ``credential_configurations_supported`` **entry** for an Open + Badge issued as SD-JWT VC (OpenID4VCI 1.0, Appendix A.3 — ``dc+sd-jwt``). + + Returns one configuration object: ``format``, ``vct``, + ``credential_signing_alg_values_supported``, + ``cryptographic_binding_methods_supported``, ``proof_types_supported`` + (``jwt``, whose ``proof_signing_alg_values_supported`` defaults to + *signing_alg_values*), ``claims`` and ``display``, plus ``scope`` when given. + Put it in your metadata document under the ``credential_configuration_id`` you + want wallets to request:: + + {"credential_configurations_supported": { + "openbadge_sd_jwt_vc": badge_credential_configuration()}} + + The two facts this exists to keep straight are the ones a hand-written copy + gets wrong: the ``vct`` is the one :func:`issue_badge_sd_jwt` actually stamps, + and the ``claims`` are exactly what :func:`badge_to_sd_jwt_claims` emits — + both derived here from :func:`badge_type_metadata`, so the Type Metadata + document an issuer serves and the configuration it advertises cannot drift + apart. ``mandatory`` mirrors that document: the achievement is always + disclosed, the recipient (``credentialSubject``) is the selectively + disclosable one (``DEFAULT_DISCLOSABLE``). + + *display* defaults to a generic English label; an issuer with a badge in hand + passes its own name, logo and locales. *signing_alg_values* defaults to + ``ES256``, the algorithm HAIP pins for EUDI wallets. + + Two things this deliberately does NOT do. It does not build the enclosing + ``/.well-known/openid-credential-issuer`` document — ``credential_issuer``, + the endpoints, the authorization servers and per-tenant display are + deployment policy, and a multi-tenant issuer has one document per tenant. And + it does not declare the revocation ``status`` claim + (:func:`issue_badge_sd_jwt`'s ``status=``): it is machine-read, never + displayed, and declaring it would also change the Type Metadata document's + bytes and therefore every already-published ``vct#integrity`` pin. + + Note on layout: OpenID4VCI 1.0 Final carries ``claims`` and ``display`` at + the top level of the configuration, which is what this emits. The EU + reference issuer has moved them under a ``credential_metadata`` object; if + you must match that deployment, nest them yourself — it is a pure dict. + """ + algs = list(signing_alg_values) + proof_algs = list(proof_signing_alg_values) if proof_signing_alg_values is not None else algs + claims: list[dict[str, Any]] = [] + for claim in badge_type_metadata(vct)["claims"]: + path = list(claim["path"]) + entry: dict[str, Any] = { + "path": path, + "mandatory": bool(claim.get("mandatory", False)), + } + label = _CLAIM_DISPLAY_NAMES.get(tuple(path)) + if label is not None: + entry["display"] = [{"name": label, "locale": locale}] + claims.append(entry) + configuration: dict[str, Any] = { + "format": OB3_SD_JWT_FORMAT, + "vct": vct, + "credential_signing_alg_values_supported": algs, + "cryptographic_binding_methods_supported": list(cryptographic_binding_methods), + "proof_types_supported": { + "jwt": {"proof_signing_alg_values_supported": proof_algs}, + }, + "claims": claims, + "display": ([dict(d) for d in display] if display is not None + else [{"name": "Open Badge 3.0", "locale": locale}]), + } + if scope is not None: + configuration["scope"] = scope + return configuration + + def issue_badge_sd_jwt( credential: OpenBadgeCredential, *, @@ -207,6 +368,7 @@ def issue_badge_sd_jwt( vct: str = OB3_SD_JWT_VCT, vct_integrity: Optional[str] = None, x5c: Optional[Iterable[str]] = None, + status: Optional[Mapping[str, Any]] = None, ) -> str: """Issue *credential* as an SD-JWT VC. @@ -227,26 +389,49 @@ def issue_badge_sd_jwt( must be in the leaf SAN, or verification fails closed. Needs openvc-core >=1.18. This closes the loop with the verify-side x5c support (#178). - IRREVOCABLE: SD-JWT VC status carriage/checking is not wired yet, so an - SD-JWT VC badge cannot be revoked. Rather than silently drop a credential's - ``credentialStatus`` (which would issue a badge the issuer believes is - revocable), this raises :class:`EudiError` if the credential carries one. - Issue a revocable OB 3.0 credential with the JWT-VC or LDP proof format - instead (#226). + REVOCATION: pass *status* — the reference + ``openvc.status.issue.build_token_status_reference(uri=…, index=…)`` builds — + to make the badge revocable through an **IETF Token Status List** (the JOSE + status format of the SD-JWT VC track). It is emitted as a ``status`` claim + that is **always disclosed**, never selectively disclosable: a holder able to + withhold the pointer could present a revoked badge as an unrevokable one, + which is worse than no status at all. Publish the list as a status-list token + (``openvc.status.issue.build_status_list_token``) at that ``uri`` and flip the + badge's bit to revoke it; the verifier side is + ``verify_badge_sd_jwt(..., resolve_status_list_token=…)``. + + Two status formats, two lists — the boundary an issuer must know before + allocating indices: the W3C **Bitstring** Status List (the JWT-VC / Data + Integrity tracks) and the IETF **Token** Status List (this one) differ in bit + order and compression, so a badge issued on both tracks needs *two* status + entries pointing at *two* lists, not one list read two ways. Accordingly a + credential carrying a W3C ``credentialStatus`` is still refused here (#226's + reasoning does not expire): its Bitstring entry cannot be honoured by an + SD-JWT verifier, and silently dropping it would issue a badge the issuer + believes is revocable. Issue the wallet copy from a credential without one + (``dataclasses.replace(cred, credential_status=[])``) and give it its own + index in the status-list token. """ if credential.credential_status: raise EudiError( - "this credential carries a credentialStatus (revocation/suspension) " - "but SD-JWT VC issuance cannot carry status yet, so the badge would " - "be irrevocable. Refusing to silently drop it — issue it without a " - "status list, or use the JWT-VC / LDP proof format for a revocable " - "OB 3.0 credential.") + "this credential carries a W3C credentialStatus (Bitstring Status " + "List), which an SD-JWT VC verifier cannot honour: the SD-JWT VC " + "track revokes through the IETF Token Status List, a different wire " + "format pointing at a different list. Refusing to silently drop it — " + "issue this copy from a credential without credentialStatus and pass " + "status=build_token_status_reference(uri=…, index=…) with its own " + "index in the status-list token.") + status_claim = _status_claim(status) if status is not None else None _, _, _, SdJwtVcProofSuite = _require_openvc() signing_key = _signing_key(privkey_pem, kid or ("%s#key-1" % credential.issuer.id)) claims = badge_to_sd_jwt_claims(credential) if vct_integrity is not None: claims["vct#integrity"] = vct_integrity - present = [name for name in disclosable if name in claims] + if status_claim is not None: + claims["status"] = status_claim + # "status" is never made selectively disclosable, whatever the caller passed + # in *disclosable* — see the docstring. + present = [name for name in disclosable if name in claims and name != "status"] try: return cast(str, SdJwtVcProofSuite().issue( claims, signing_key=signing_key, disclosable=present, vct=vct, @@ -257,6 +442,103 @@ def issue_badge_sd_jwt( raise EudiError("could not issue SD-JWT VC badge: %s" % exc) from exc +def status_list_token_resolver( + *, + pubkey_pem: Union[str, bytes], + download: Optional[Callable[[str], bytes]] = None, + leeway_s: int = 60, +) -> Callable[[str], dict[str, Any]]: + """Build the ``resolve_status_list_token`` callable :func:`verify_badge_sd_jwt` + takes: fetch the status-list token at a URI and **verify** it with + *pubkey_pem* before its bits are read. + + The verification is openvc-core's ``verify_status_list_token`` — algorithm + allow-list, ``typ: statuslist+jwt``, signature, ``exp`` — plus the IETF + anti-swap check that the token's ``sub`` equals the URI it was fetched from, + which this passes for you: without it a status list served at one URL could + be replayed at another to un-revoke a badge. *download* defaults to + :func:`openbadgeslib.util.download_file` (HTTPS-only, SSRF-guarded, + size-capped) and is injectable for testing or for a private deployment. + + A resolver is deliberately caller-supplied rather than implicit: the key that + signs the status list is a trust decision (typically the badge issuer's own + key, as ``openbadges-publish`` does for the Bitstring track). Writing your + own is fine — it must return the verified token's claims dict. + """ + fetch = download if download is not None else download_file + + def resolve(uri: str) -> dict[str, Any]: + try: + from openvc.status.issue import verify_status_list_token + except ImportError as exc: + raise EudiError(_INSTALL_HINT) from exc + raw = fetch(uri) + token = (raw.decode("ascii").strip() if isinstance(raw, bytes) + else str(raw).strip()) + claims: dict[str, Any] = verify_status_list_token( + token, public_key_jwk=public_jwk_from_pem(pubkey_pem), + expected_uri=uri, leeway_s=leeway_s) + return claims + + return resolve + + +def _check_sd_jwt_status(claims: Mapping[str, Any], *, require_status: bool, + resolve_status_list_token: Any) -> Any: + """Check the IETF ``status`` reference on a verified badge's claims. + + Mirrors what openvc-core's ``verify_credential`` does for the x5c path, for + the pinned-key path (whose suite-level verify has no status stage): resolve + the status-list token and raise :class:`EudiError` if this badge's bit says + revoked or suspended. Returns openvc-core's ``TokenStatusResult`` (or None + when the badge declares no status). + + Fail-closed: a declared status with no resolver raises when *require_status* + is set, and any resolve/decode failure raises whenever a resolver is given — + an unreadable list is never read as "not revoked". + """ + check_token_status, parse_token_status_ref = _require_openvc_status() + try: + ref = parse_token_status_ref(dict(claims)) + except Exception as exc: # malformed reference + raise EudiError("badge carries a malformed status reference: %s" % exc) from exc + if ref is None: + # A `status` claim of an unrecognised shape is not "no status": treat it + # like the delegate does and fail closed when status is required. + if require_status and claims.get("status") is not None: + raise EudiError( + "badge declares a status of an unrecognised shape and it cannot " + "be checked (pass require_status=False to skip)") + if require_status and claims.get("credentialStatus") is not None: + raise EudiError( + "badge declares a W3C credentialStatus, which this track does " + "not check: SD-JWT VC status is the IETF Token Status List " + "(pass require_status=False to skip)") + return None + if resolve_status_list_token is None: + if require_status: + raise EudiError( + "badge declares a revocation status (%s) but no " + "resolve_status_list_token was supplied to check it" % ref.uri) + return None + try: + result = check_token_status( + dict(claims), resolve_status_list_token=resolve_status_list_token) + except EudiError: + raise + except Exception as exc: + raise EudiError( + "could not check the badge's revocation status at %s: %s" + % (ref.uri, exc)) from exc + if result is not None and result.revoked: + raise EudiError("badge is revoked (status list %s, index %d)" + % (result.ref.uri, result.ref.index)) + if result is not None and result.suspended: + raise EudiError("badge is suspended (status list %s, index %d)" + % (result.ref.uri, result.ref.index)) + return result + + def verify_badge_sd_jwt( token: str, *, @@ -266,6 +548,8 @@ def verify_badge_sd_jwt( require_key_binding: bool = False, expected_vct: Optional[str] = OB3_SD_JWT_VCT, x5c_trust_anchors: Any = None, + require_status: bool = False, + resolve_status_list_token: Any = None, ) -> Any: """Verify a badge SD-JWT VC (issuer form or a holder presentation). @@ -274,10 +558,17 @@ def verify_badge_sd_jwt( Pass *audience*/*nonce* (and ``require_key_binding=True``) to check a Key Binding JWT from a holder presentation. - NOTE: revocation status is NOT checked (``require_status=False`` on both - paths). SD-JWT VC badges are issued without a ``credentialStatus`` - (:func:`issue_badge_sd_jwt` rejects one), so there is nothing to check — an - SD-JWT VC badge is currently irrevocable (#226). + Revocation (IETF Token Status List) is checked when you supply + *resolve_status_list_token* — the callable + :func:`status_list_token_resolver` builds, or your own ``uri -> claims`` + fetch-and-verify. A badge whose bit says revoked or suspended raises + :class:`EudiError`, on both trust paths. Without a resolver the status is not + checked and a badge that declares one still verifies, unless you also pass + ``require_status=True``, which turns an uncheckable status into a failure + (fail-closed, and the same knob name and meaning as the delegate's + ``VerificationPolicy.require_status``). A badge that declares no status is + unaffected by either flag: it verifies, and it is simply not revocable + (:func:`issue_badge_sd_jwt` without ``status=``). Trust comes from exactly one of two sources: @@ -291,8 +582,9 @@ def verify_badge_sd_jwt( is used — routed through openvc-core's ``verify_credential`` pipeline (the JWK-pin suite path cannot do X.509 trust). A token that carries no ``x5c`` chain is rejected in this mode: the anchors are never bypassed by falling - back to DID / issuer-URL key resolution. Status is not checked here, as - with the pinned path. + back to DID / issuer-URL key resolution. Credential status is checked the + same way as on the pinned path — here by the delegate itself, which is + handed the same *require_status* / *resolve_status_list_token*. X.509 / eIDAS trust boundary — division of responsibility --------------------------------------------------------- @@ -324,7 +616,9 @@ def verify_badge_sd_jwt( return _verify_sd_jwt_x5c( token, x5c_trust_anchors, expected_vct=expected_vct, audience=audience, nonce=nonce, - require_key_binding=require_key_binding) + require_key_binding=require_key_binding, + require_status=require_status, + resolve_status_list_token=resolve_status_list_token) if pubkey_pem is None: raise EudiError( "verify_badge_sd_jwt needs either pubkey_pem (pin the issuer's key) " @@ -332,13 +626,19 @@ def verify_badge_sd_jwt( _, _, _, SdJwtVcProofSuite = _require_openvc() try: public_key_jwk = public_jwk_from_pem(pubkey_pem) - return SdJwtVcProofSuite().verify( + verified = SdJwtVcProofSuite().verify( token, public_key_jwk=public_key_jwk, audience=audience, nonce=nonce, require_key_binding=require_key_binding, expected_vct=expected_vct) except EudiError: raise except Exception as exc: raise EudiError("SD-JWT VC badge verification failed: %s" % exc) from exc + # The suite verifies the signature and the disclosures but has no status + # stage (the delegate does status one layer up, in verify_credential — which + # is the x5c path). Check it here so both paths honour the same seam. + _check_sd_jwt_status(verified.claims, require_status=require_status, + resolve_status_list_token=resolve_status_list_token) + return verified def _issuer_jwt_has_x5c(token: str) -> bool: @@ -360,7 +660,9 @@ def _issuer_jwt_has_x5c(token: str) -> bool: def _verify_sd_jwt_x5c(token: str, x5c_trust_anchors: Any, *, expected_vct: Optional[str], audience: Optional[str], - nonce: Optional[str], require_key_binding: bool) -> Any: + nonce: Optional[str], require_key_binding: bool, + require_status: bool = False, + resolve_status_list_token: Any = None) -> Any: """Verify a badge SD-JWT whose issuer JWT carries an ``x5c`` chain against *x5c_trust_anchors* (X.509 roots), via openvc-core's ``verify_credential`` pipeline — which path-validates the chain and binds it to ``iss`` before @@ -368,7 +670,13 @@ def _verify_sd_jwt_x5c(token: str, x5c_trust_anchors: Any, *, See :func:`verify_badge_sd_jwt` for the trust-boundary contract: openvc-core owns the chain verdict (temporal validity, name chaining, SAN↔iss binding); - certificate revocation (CRL/OCSP) is NOT checked by either layer (#236).""" + certificate revocation (CRL/OCSP) is NOT checked by either layer (#236). + + *Credential* status (the badge's own IETF Token Status List reference, which + is a different thing from certificate revocation) is checked by the delegate + on this path: the policy carries *require_status* and the resolver is passed + straight through, so a revoked badge raises ``CredentialRevoked`` inside + openvc-core and surfaces here as :class:`EudiError`.""" try: from openvc import VerificationPolicy, verify_credential except ImportError as exc: @@ -385,10 +693,11 @@ def _verify_sd_jwt_x5c(token: str, x5c_trust_anchors: Any, *, "key resolution, which would bypass the X.509 trust anchors") policy = VerificationPolicy( expected_vct=expected_vct, audience=audience, nonce=nonce, - require_key_binding=require_key_binding, require_status=False) + require_key_binding=require_key_binding, require_status=require_status) try: result = verify_credential( - token, policy=policy, x5c_trust_anchors=x5c_trust_anchors) + token, policy=policy, x5c_trust_anchors=x5c_trust_anchors, + resolve_status_list_token=resolve_status_list_token) except EudiError: raise except Exception as exc: diff --git a/pyproject.toml b/pyproject.toml index 61cdfb5..760f179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,13 @@ dependencies = [ # engine is structurally immune — it never calls jwt.decode — but the OB3 # path is not.) "PyJWT[crypto]>=2.13", - "cryptography>=42", + # Floor at 45, openvc-core's own core pin — so an install that takes the + # [eudi]/[ldp-sd] extras resolves one range, not two. It is deliberately + # BELOW cryptography 47, where loading a key of an unsupported algorithm + # started raising UnsupportedAlgorithm instead of ValueError: 47 is recent + # (2026-04) and pinning it would buy nothing, because keys.py handles both + # exception contracts in one place and tests both (#244). + "cryptography>=45", "defusedxml>=0.7", ] @@ -63,8 +69,12 @@ ldp = [ # SemVer/deprecation contract, so the pin follows SemVer: floor at the version # the suite is tested against, ceiling at the next major. # See openbadgeslib/ob3/eudi.py. +# The floor is >=1.22 in substance — IETF Token Status List revocation +# (openvc.status.issue.build_token_status_reference, .token_status_list. +# check_token_status) landed there — and is set at the release the suite is +# actually tested against, per the rule above. eudi = [ - "openvc-core>=1.21,<2", + "openvc-core>=1.23,<2", ] # ecdsa-sd-2023 (selective disclosure) VERIFY for the OB 3.0 Data Integrity # track: credentials secured with the ecdsa-sd-2023 cryptosuite are verified by diff --git a/tests/test_keys.py b/tests/test_keys.py index 31e3c41..aac1e80 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -4,6 +4,7 @@ libraries stopped being dependencies in #167. """ import pytest +from cryptography.exceptions import UnsupportedAlgorithm from cryptography.hazmat.primitives.serialization import load_pem_private_key from openbadgeslib import keys @@ -61,3 +62,128 @@ def test_non_ec_key_returns_none(self, rsa_pub_pem): def test_unreadable_pem_returns_none(self): assert keys.ec_curve_from_pem(b'not a pem at all') is None + + +# ── #244: the cryptography exception contract across the supported floor ───── + +class TestKeyLoadErrorContract: + """A failed PEM load must land in the library's taxonomy whatever + `cryptography` is resolved. + + v47 changed the contract: loading a key of an unsupported algorithm raises + `UnsupportedAlgorithm` — which is NOT a ValueError — where earlier versions + raised ValueError. Our floor (>=45) spans that change, so both are asserted + here; a catch-clause that handled only one would let a raw crypto exception + escape as a traceback on half the supported range. + """ + + GARBAGE = b'-----BEGIN PUBLIC KEY-----\nbm90IGEga2V5\n-----END PUBLIC KEY-----\n' + + def test_garbage_public_pem_raises_public_key_read_error(self): + from openbadgeslib.errors import PublicKeyReadError + from openbadgeslib.keys import KeyRSA + with pytest.raises(PublicKeyReadError): + KeyRSA().read_public_key(self.GARBAGE) + + def test_garbage_private_pem_raises_private_key_read_error(self): + from openbadgeslib.errors import PrivateKeyReadError + from openbadgeslib.keys import KeyECC + with pytest.raises(PrivateKeyReadError): + KeyECC().read_private_key(b'-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n') + + @pytest.mark.parametrize('raised', [ + ValueError('unsupported key type'), # cryptography <47 + UnsupportedAlgorithm('unsupported key type'), # cryptography >=47 + ]) + def test_both_exception_contracts_map_to_the_taxonomy(self, monkeypatch, + raised, rsa_pub_pem, + rsa_priv_pem): + from openbadgeslib import keys as keys_mod + from openbadgeslib.errors import (KeyGenExceptions, PrivateKeyReadError, + PublicKeyReadError) + + def boom(*a, **kw): + raise raised + + monkeypatch.setattr(keys_mod._crypto_serialization, + 'load_pem_public_key', boom) + monkeypatch.setattr(keys_mod._crypto_serialization, + 'load_pem_private_key', boom) + with pytest.raises(PublicKeyReadError) as pub: + keys_mod.KeyRSA().read_public_key(rsa_pub_pem) + with pytest.raises(PrivateKeyReadError): + keys_mod.KeyRSA().read_private_key(rsa_priv_pem) + assert isinstance(pub.value, KeyGenExceptions) # one family to catch + assert pub.value.__cause__ is raised # the real cause kept + + @pytest.mark.parametrize('raised', [ + ValueError('unsupported key type'), + UnsupportedAlgorithm('unsupported key type'), + ]) + def test_ed25519_loaders_map_both_contracts(self, monkeypatch, raised, + ed25519_pub_pem): + from openbadgeslib import keys as keys_mod + from openbadgeslib.errors import PublicKeyReadError + + def boom(*a, **kw): + raise raised + + monkeypatch.setattr(keys_mod._crypto_serialization, + 'load_pem_public_key', boom) + with pytest.raises(PublicKeyReadError): + keys_mod.KeyEd25519().read_public_key(ed25519_pub_pem) + + @pytest.mark.parametrize('raised', [ + ValueError('unsupported key type'), + UnsupportedAlgorithm('unsupported key type'), + ]) + def test_detection_helpers_stay_total_under_both(self, monkeypatch, raised, + rsa_pub_pem): + # detect_key_type/ec_curve_from_pem probe with both loaders and must not + # leak either exception: one reports UnknownKeyType, the other None. + from openbadgeslib import keys as keys_mod + from openbadgeslib.errors import UnknownKeyType + + def boom(*a, **kw): + raise raised + + monkeypatch.setattr(keys_mod._crypto_serialization, + 'load_pem_public_key', boom) + monkeypatch.setattr(keys_mod._crypto_serialization, + 'load_pem_private_key', boom) + with pytest.raises(UnknownKeyType): + keys_mod.detect_key_type(rsa_pub_pem) + assert keys_mod.ec_curve_from_pem(rsa_pub_pem) is None + + @pytest.mark.parametrize('raised', [ + ValueError('unsupported key type'), + UnsupportedAlgorithm('unsupported key type'), + ]) + def test_public_jwk_from_pem_maps_both(self, monkeypatch, raised, + rsa_pub_pem): + from openbadgeslib import keys as keys_mod + from openbadgeslib.errors import PublicKeyReadError + + def boom(*a, **kw): + raise raised + + monkeypatch.setattr(keys_mod._crypto_serialization, + 'load_pem_public_key', boom) + with pytest.raises(PublicKeyReadError): + keys_mod.public_jwk_from_pem(rsa_pub_pem) + + +class TestRsaKeySizeFloor: + """cryptography v48 enforces a 1024-bit floor on rsa.generate_private_key; + an absurd configured size must be a library error, not a raw traceback.""" + + def test_undersized_rsa_key_raises_gen_private_key_error(self): + from openbadgeslib.errors import GenPrivateKeyError + from openbadgeslib.keys import KeyRSA + with pytest.raises(GenPrivateKeyError): + KeyRSA(key_size=512).generate_keypair() + + def test_normal_size_still_generates(self): + from openbadgeslib.keys import KeyRSA + priv, pub = KeyRSA().generate_keypair() + assert b'PRIVATE KEY' in priv and b'PUBLIC KEY' in pub diff --git a/tests/test_ob3_contexts.py b/tests/test_ob3_contexts.py index 5a50a5c..3bf9854 100644 --- a/tests/test_ob3_contexts.py +++ b/tests/test_ob3_contexts.py @@ -139,3 +139,24 @@ def test_every_bundled_context_has_recorded_provenance(): 'context files without a recorded SHA-256: %s; digests recorded for ' 'files that are not shipped: %s' % (sorted(shipped - recorded), sorted(recorded - shipped))) + + +def test_gitattributes_pins_the_contexts_against_eol_conversion(): + """The digests above are only checkable if git leaves the bytes alone. + + Without a `-text` attribute git rewrites these files to CRLF on a Windows + checkout, so every recorded digest mismatches and the provenance control + cannot be evaluated on that platform at all — which is how the Windows CI + leg went red for three days (#274). Pin the attribute, not just the digests. + """ + from pathlib import Path + root = Path(__file__).resolve().parent.parent + attributes = (root / '.gitattributes') + assert attributes.is_file(), '.gitattributes is missing (#274)' + rules = [line.split('#', 1)[0].split() + for line in attributes.read_text(encoding='utf-8').splitlines() + if line.strip() and not line.lstrip().startswith('#')] + assert any(rule and rule[0].endswith('ob3/contexts/*.json') + and '-text' in rule[1:] for rule in rules), ( + '.gitattributes must mark openbadgeslib/ob3/contexts/*.json as -text so ' + 'git never rewrites the line endings the recorded SHA-256 digests cover') diff --git a/tests/test_ob3_eudi_oid4vci.py b/tests/test_ob3_eudi_oid4vci.py new file mode 100644 index 0000000..85b6ccf --- /dev/null +++ b/tests/test_ob3_eudi_oid4vci.py @@ -0,0 +1,133 @@ +"""Tests for the OpenID4VCI credential configuration entry (#271). + +`badge_credential_configuration` is the Open Badges half of an OpenID4VCI +issuer's Credential Issuer Metadata: what a wallet reads to decide it can ask +for this badge, and how to display it. It is pure dict building — no openvc, no +[eudi] extra — so what these tests pin is that it cannot drift away from what +the issuance path actually stamps. +""" +import pytest + +from openbadgeslib.ob3.eudi import ( + DEFAULT_DISCLOSABLE, + OB3_SD_JWT_FORMAT, + OB3_SD_JWT_VCT, + badge_credential_configuration, + badge_to_sd_jwt_claims, + badge_type_metadata, +) + + +class TestCredentialConfigurationShape: + def test_defaults(self): + cfg = badge_credential_configuration() + assert cfg["format"] == OB3_SD_JWT_FORMAT == "dc+sd-jwt" + assert cfg["vct"] == OB3_SD_JWT_VCT + assert cfg["credential_signing_alg_values_supported"] == ["ES256"] + assert cfg["cryptographic_binding_methods_supported"] == ["jwk"] + assert cfg["proof_types_supported"]["jwt"][ + "proof_signing_alg_values_supported"] == ["ES256"] + assert cfg["display"] == [{"name": "Open Badge 3.0", "locale": "en"}] + assert "scope" not in cfg # only when asked for + + def test_caller_overrides(self): + cfg = badge_credential_configuration( + vct="https://issuer.example/vct/badge", + signing_alg_values=("ES256", "ES384"), + proof_signing_alg_values=("ES256",), + cryptographic_binding_methods=("jwk", "did:web"), + display=[{"name": "Python 101", "locale": "es"}], + scope="openbadge") + assert cfg["vct"] == "https://issuer.example/vct/badge" + assert cfg["credential_signing_alg_values_supported"] == ["ES256", "ES384"] + assert cfg["proof_types_supported"]["jwt"][ + "proof_signing_alg_values_supported"] == ["ES256"] + assert cfg["cryptographic_binding_methods_supported"] == ["jwk", "did:web"] + assert cfg["display"] == [{"name": "Python 101", "locale": "es"}] + assert cfg["scope"] == "openbadge" + + def test_display_is_copied_not_aliased(self): + mine = [{"name": "Mine", "locale": "en"}] + cfg = badge_credential_configuration(display=mine) + cfg["display"][0]["name"] = "Changed" + assert mine[0]["name"] == "Mine" + + def test_is_json_serializable(self): + import json + json.loads(json.dumps(badge_credential_configuration())) + + +class TestNoDrift: + """The reason this function exists: a hand-written copy gets the vct and the + claim set wrong, and a wallet then either rejects the credential or displays + nothing.""" + + def test_vct_matches_the_issued_badge(self, ob3_credential, ed25519_priv_pem): + pytest.importorskip("openvc") + import base64 + import json + from openbadgeslib.ob3.eudi import issue_badge_sd_jwt + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem) + body = token.split("~", 1)[0].split(".")[1] + stamped = json.loads( + base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))["vct"] + assert badge_credential_configuration()["vct"] == stamped + + def test_custom_vct_flows_through_both(self, ob3_credential, ed25519_priv_pem): + pytest.importorskip("openvc") + import base64 + import json + from openbadgeslib.ob3.eudi import issue_badge_sd_jwt + vct = "https://issuer.example/vct/ob3" + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + vct=vct) + body = token.split("~", 1)[0].split(".")[1] + stamped = json.loads( + base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))["vct"] + assert badge_credential_configuration(vct=vct)["vct"] == stamped == vct + + def test_claim_paths_match_the_type_metadata_document(self): + # Both artifacts are served by the same issuer and validated against + # each other by a wallet, so they are derived from one source here. + cfg = badge_credential_configuration() + metadata = badge_type_metadata() + assert [c["path"] for c in cfg["claims"]] == [ + c["path"] for c in metadata["claims"]] + assert [c["mandatory"] for c in cfg["claims"]] == [ + bool(c.get("mandatory", False)) for c in metadata["claims"]] + + def test_claim_paths_resolve_in_an_issued_claim_set(self, ob3_credential): + # Every advertised top-level path exists in what badge_to_sd_jwt_claims + # emits (validUntil is optional and absent from this fixture). + claims = badge_to_sd_jwt_claims(ob3_credential) + advertised = {tuple(c["path"]) for c in + badge_credential_configuration()["claims"]} + assert ("achievement",) in advertised + assert ("credentialSubject",) in advertised + for path in advertised: + if path[0] in claims: + node = claims + for component in path: + assert component in node, path + node = node[component] + + def test_disclosable_claim_is_not_mandatory(self): + # credentialSubject is what a holder may withhold, so a wallet must not + # be told the credential always carries it. + mandatory = {tuple(c["path"]): c["mandatory"] + for c in badge_credential_configuration()["claims"]} + for name in DEFAULT_DISCLOSABLE: + assert mandatory[(name,)] is False + assert mandatory[("achievement",)] is True + + def test_claims_carry_display_labels(self): + for claim in badge_credential_configuration(locale="es")["claims"]: + assert claim["display"][0]["locale"] == "es" + assert claim["display"][0]["name"] + + +def test_works_without_the_eudi_extra(monkeypatch): + import sys + for name in ("openvc", "openvc.keys", "openvc.proof.sd_jwt"): + monkeypatch.setitem(sys.modules, name, None) + assert badge_credential_configuration()["format"] == "dc+sd-jwt" diff --git a/tests/test_ob3_eudi_sd_jwt.py b/tests/test_ob3_eudi_sd_jwt.py index 770f07a..b981b62 100644 --- a/tests/test_ob3_eudi_sd_jwt.py +++ b/tests/test_ob3_eudi_sd_jwt.py @@ -143,12 +143,13 @@ def test_missing_openvc_yields_actionable_error(self, monkeypatch, issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem) -# ── #226: irrevocability + identifier carriage (pure-Python, no [eudi]) ─────── +# ── #226/#270: status carriage + identifier carriage (pure-Python, no [eudi]) ─ class TestSdJwtStatusAndIdentifier: - """SD-JWT VC badges are irrevocable, so a credentialStatus is rejected at - issuance (not silently dropped), and the recipient's hashed identifier is - carried under credentialSubject. These paths need no openvc. + """A W3C credentialStatus is rejected at issuance (not silently dropped) — + the SD-JWT VC track revokes through the IETF Token Status List instead + (#270) — and the recipient's hashed identifier is carried under + credentialSubject. These paths need no openvc. ``ob3_credential`` is a session fixture — never mutate it; derive a variant with ``dataclasses.replace`` so other tests keep the pristine credential. @@ -167,11 +168,14 @@ def _identifier(self, ihash="sha256$abc"): return IdentityObject(identity_hash=ihash, identity_type="emailAddress", hashed=True) - def test_issue_rejects_credential_with_status(self, ob3_credential, - ed25519_priv_pem): + def test_issue_rejects_credential_with_w3c_status(self, ob3_credential, + ed25519_priv_pem): + # Not because the badge cannot be revoked (it can, via status=), but + # because a Bitstring entry points at a list an SD-JWT verifier cannot + # read: two formats, two lists. from dataclasses import replace cred = replace(ob3_credential, credential_status=list(self._STATUS)) - with pytest.raises(EudiError, match="irrevocable"): + with pytest.raises(EudiError, match="IETF Token Status List"): issue_badge_sd_jwt(cred, privkey_pem=ed25519_priv_pem) def test_rejection_precedes_the_openvc_requirement(self, ob3_credential): @@ -204,3 +208,259 @@ def test_no_status_credential_still_issues_claims(self, ob3_credential): claims = badge_to_sd_jwt_claims(ob3_credential) assert "credentialStatus" not in claims assert claims["credentialSubject"]["id"] == ob3_credential.recipient_id + + +# ── #270: revocation via the IETF Token Status List ────────────────────────── + +class TestTokenStatusList: + """Issue a badge carrying an IETF status reference, verify it, flip the bit, + re-sign the list and watch the same badge come back revoked. + + Everything about the status list itself — the LSB-first packing, DEFLATE, + the token's typ/signature — is openvc-core's; these tests pin the seam this + library owns: that the reference is carried always-disclosed, that both + verify paths reach the check, and that every way of not knowing the status + fails closed. + """ + + STATUS_URI = "https://issuer.example/status/list-1.jwt" + INDEX = 7 + + @pytest.fixture(autouse=True) + def _needs_openvc(self): + pytest.importorskip("openvc") + + # ── helpers ────────────────────────────────────────────────────────────── + + @staticmethod + def _signing_key(priv_pem): + from openvc.keys import Ed25519SigningKey + return Ed25519SigningKey.from_pem(priv_pem, kid="status-key") + + def _reference(self, index=None): + from openvc.status.issue import build_token_status_reference + return build_token_status_reference( + uri=self.STATUS_URI, index=self.INDEX if index is None else index) + + def _list_token(self, priv_pem, *, revoked=(), suspended=(), bits=1, + uri=None, size=64, **kwargs): + """Sign a status-list token with the given indices flipped.""" + from openvc.status.issue import build_status_list_token + from openvc.status.token_status_list import new_status_list, set_status + data = new_status_list(size, bits=bits) + for idx in revoked: + set_status(data, idx, 1, bits=bits) + for idx in suspended: + set_status(data, idx, 2, bits=bits) + return build_status_list_token( + signing_key=self._signing_key(priv_pem), + uri=uri or self.STATUS_URI, status_list=data, bits=bits, **kwargs) + + def _resolver(self, list_token, pub_pem, *, uri=None): + """A resolve_status_list_token that serves *list_token* over a fake + download, verified by the real status_list_token_resolver.""" + from openbadgeslib.ob3.eudi import status_list_token_resolver + served = {} + served[uri or self.STATUS_URI] = list_token.encode("ascii") + + def download(url): + try: + return served[url] + except KeyError: # a URL nobody published + raise OSError("404 %s" % url) + + return status_list_token_resolver(pubkey_pem=pub_pem, download=download) + + @staticmethod + def _payload(token): + """The issuer JWT's payload — what is disclosed unconditionally.""" + import base64 + import json + body = token.split("~", 1)[0].split(".")[1] + return json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))) + + # ── issuance ───────────────────────────────────────────────────────────── + + def test_status_reference_is_carried(self, ob3_credential, ed25519_priv_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + status = self._payload(token)["status"] + assert status["status_list"] == {"uri": self.STATUS_URI, "idx": self.INDEX} + + def test_status_is_never_selectively_disclosable(self, ob3_credential, + ed25519_priv_pem, + ed25519_pub_pem): + # Even asked for explicitly: a holder able to withhold the pointer could + # present a revoked badge as an unrevokable one. + token = issue_badge_sd_jwt( + ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference(), disclosable=("credentialSubject", "status")) + assert "status" in self._payload(token) # in the JWT, not a digest + # And it survives a presentation that drops every disclosure. + bare = token.split("~", 1)[0] + "~" + result = verify_badge_sd_jwt(bare, pubkey_pem=ed25519_pub_pem) + assert result.claims["status"]["status_list"]["idx"] == self.INDEX + assert "credentialSubject" not in result.claims # that one IS withheld + + def test_inner_reference_shape_is_accepted(self, ob3_credential, + ed25519_priv_pem): + # build_token_status_reference returns the {"status": …} wrapper; the + # inner object is accepted too, so neither shape is a footgun. + inner = self._reference()["status"] + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=inner) + assert self._payload(token)["status"] == inner + + @pytest.mark.parametrize("bad", [ + {"status": {}}, # no status_list + {"status": {"status_list": {"uri": "https://x/l"}}}, # no idx + {"status": {"status_list": {"uri": "https://x/l", "idx": -1}}}, + {"status": {"status_list": {"uri": "https://x/l", "idx": "3"}}}, + "not-a-mapping", + ]) + def test_malformed_reference_is_rejected_at_issuance(self, bad, + ob3_credential, + ed25519_priv_pem): + # Rejected here rather than producing a badge whose status can never be + # read — an unreadable status is indistinguishable from no status. + with pytest.raises(EudiError): + issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=bad) + + # ── the round trip ─────────────────────────────────────────────────────── + + def test_roundtrip_valid_then_revoked(self, ob3_credential, ed25519_priv_pem, + ed25519_pub_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + # 1. nothing flipped: verifies, and reports the status it checked + live = self._resolver(self._list_token(ed25519_priv_pem), ed25519_pub_pem) + result = verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + require_status=True, + resolve_status_list_token=live) + assert result.claims["achievement"]["name"] == ob3_credential.achievement.name + + # 2. flip this badge's bit and re-sign the list: the same badge is revoked + revoked = self._resolver( + self._list_token(ed25519_priv_pem, revoked=[self.INDEX]), + ed25519_pub_pem) + with pytest.raises(EudiError, match="revoked"): + verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + require_status=True, + resolve_status_list_token=revoked) + + def test_a_neighbours_revocation_does_not_revoke_this_badge( + self, ob3_credential, ed25519_priv_pem, ed25519_pub_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + others = self._resolver( + self._list_token(ed25519_priv_pem, revoked=[self.INDEX - 1, + self.INDEX + 1]), + ed25519_pub_pem) + result = verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + require_status=True, + resolve_status_list_token=others) + assert result.issuer == ob3_credential.issuer.id + + def test_suspended_is_rejected_too(self, ob3_credential, ed25519_priv_pem, + ed25519_pub_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + suspended = self._resolver( + self._list_token(ed25519_priv_pem, suspended=[self.INDEX], bits=2), + ed25519_pub_pem) + with pytest.raises(EudiError, match="suspended"): + verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + require_status=True, + resolve_status_list_token=suspended) + + # ── fail-closed ────────────────────────────────────────────────────────── + + def test_declared_status_without_resolver_fails_when_required( + self, ob3_credential, ed25519_priv_pem, ed25519_pub_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + with pytest.raises(EudiError, match="no resolve_status_list_token"): + verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + require_status=True) + + def test_declared_status_without_resolver_is_skipped_by_default( + self, ob3_credential, ed25519_priv_pem, ed25519_pub_pem): + # The pre-#270 behaviour for callers that never opted in. + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + result = verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem) + assert result.issuer == ob3_credential.issuer.id + + def test_unresolvable_list_is_not_read_as_valid(self, ob3_credential, + ed25519_priv_pem, + ed25519_pub_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + # The list is published somewhere else entirely: the fetch fails. + missing = self._resolver(self._list_token(ed25519_priv_pem), + ed25519_pub_pem, uri="https://elsewhere/l.jwt") + with pytest.raises(EudiError, match="could not check"): + verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + resolve_status_list_token=missing) + + def test_status_list_signed_by_another_key_is_rejected( + self, ob3_credential, ed25519_priv_pem, ed25519_pub_pem, + ecc_priv_pem, ecc_pub_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + # The list is signed by a key the verifier does not trust: a compromised + # status host must not be able to un-revoke (or revoke) a badge. + from openvc.keys import P256SigningKey + from openvc.status.issue import build_status_list_token + from openvc.status.token_status_list import new_status_list + foreign = build_status_list_token( + signing_key=P256SigningKey.from_pem(ecc_priv_pem, kid="other"), + uri=self.STATUS_URI, status_list=new_status_list(64)) + resolver = self._resolver(foreign, ed25519_pub_pem) + with pytest.raises(EudiError, match="could not check"): + verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + resolve_status_list_token=resolver) + + def test_list_replayed_from_another_url_is_rejected(self, ob3_credential, + ed25519_priv_pem, + ed25519_pub_pem): + # The IETF anti-swap check: a valid list token published at one URL, + # served at the URL this badge points to, must not be accepted — it + # would un-revoke every badge in the real list. + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + elsewhere = self._list_token(ed25519_priv_pem, + uri="https://issuer.example/status/other.jwt") + resolver = self._resolver(elsewhere, ed25519_pub_pem) # served at OUR uri + with pytest.raises(EudiError, match="could not check"): + verify_badge_sd_jwt(token, pubkey_pem=ed25519_pub_pem, + resolve_status_list_token=resolver) + + def test_expired_status_list_is_rejected(self, ob3_credential, + ed25519_priv_pem, ed25519_pub_pem): + # A lapsed list must not keep answering "not revoked" forever. + import time + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem, + status=self._reference()) + stale = self._list_token(ed25519_priv_pem, + expires=int(time.time()) - 3600) + with pytest.raises(EudiError, match="could not check"): + verify_badge_sd_jwt( + token, pubkey_pem=ed25519_pub_pem, + resolve_status_list_token=self._resolver(stale, ed25519_pub_pem)) + + # ── badges without a status ────────────────────────────────────────────── + + def test_badge_without_status_verifies_and_says_so(self, ob3_credential, + ed25519_priv_pem, + ed25519_pub_pem): + token = issue_badge_sd_jwt(ob3_credential, privkey_pem=ed25519_priv_pem) + assert "status" not in self._payload(token) + # require_status is about a status that cannot be CHECKED, not about + # demanding every badge carry one — same meaning as the delegate's. + result = verify_badge_sd_jwt( + token, pubkey_pem=ed25519_pub_pem, require_status=True, + resolve_status_list_token=self._resolver( + self._list_token(ed25519_priv_pem), ed25519_pub_pem)) + assert result.issuer == ob3_credential.issuer.id diff --git a/tests/test_ob3_eudi_x5c.py b/tests/test_ob3_eudi_x5c.py index 735e4ce..a86a5c0 100644 --- a/tests/test_ob3_eudi_x5c.py +++ b/tests/test_ob3_eudi_x5c.py @@ -82,7 +82,7 @@ def der_b64(crt): return [der_b64(leaf), der_b64(inter)], root, leaf_key -def _sd_jwt_with_x5c(leaf_key, x5c, *, iss=ISS, vct=OB3_SD_JWT_VCT): +def _sd_jwt_with_x5c(leaf_key, x5c, *, iss=ISS, vct=OB3_SD_JWT_VCT, status=None): """A minimal badge SD-JWT (no disclosures) whose issuer JWT carries *x5c*.""" from openvc.keys import P256SigningKey from openvc.proof._jws import sign_compact @@ -91,6 +91,8 @@ def _sd_jwt_with_x5c(leaf_key, x5c, *, iss=ISS, vct=OB3_SD_JWT_VCT): payload = {'iss': iss, 'vct': vct, 'iat': int(time.time()), 'name': 'Python 101', 'achievement': {'id': 'https://ex/b/1', 'name': 'Python 101'}} + if status is not None: + payload.update(status) return sign_compact( header, payload, signing_key=P256SigningKey(leaf_key, kid='leaf')) + '~' @@ -107,6 +109,55 @@ def test_trusted_anchor_verifies_and_binds_issuer(self): assert result.issuer == ISS assert result.vct == OB3_SD_JWT_VCT + def test_revoked_badge_is_rejected_on_the_x5c_path(self): + # #270: credential status is reachable on BOTH trust paths. Here the + # delegate does the check (verify_credential owns it); the seam this + # library owns is passing require_status/the resolver through, and + # surfacing CredentialRevoked as EudiError. + from cryptography.hazmat.primitives import serialization + from openbadgeslib.ob3.eudi import status_list_token_resolver + from openvc.keys import P256SigningKey + from openvc.status.issue import (build_status_list_token, + build_token_status_reference) + from openvc.status.token_status_list import new_status_list, set_status + + uri = 'https://issuer.example/status/x5c-list.jwt' + x5c, root, leaf_key = _chain() + leaf_pub_pem = leaf_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo) + token = _sd_jwt_with_x5c( + leaf_key, x5c, status=build_token_status_reference(uri=uri, index=3)) + + def signed_list(*revoked): + data = new_status_list(64) + for idx in revoked: + set_status(data, idx, 1) + list_token = build_status_list_token( + signing_key=P256SigningKey(leaf_key, kid='leaf'), uri=uri, + status_list=data) + return status_list_token_resolver( + pubkey_pem=leaf_pub_pem, download=lambda _u: list_token.encode()) + + ok = verify_badge_sd_jwt(token, x5c_trust_anchors=[root], + require_status=True, + resolve_status_list_token=signed_list()) + assert ok.issuer == ISS + with pytest.raises(EudiError, match='revoked'): + verify_badge_sd_jwt(token, x5c_trust_anchors=[root], + require_status=True, + resolve_status_list_token=signed_list(3)) + + def test_declared_status_unresolvable_fails_closed_on_the_x5c_path(self): + from openvc.status.issue import build_token_status_reference + x5c, root, leaf_key = _chain() + token = _sd_jwt_with_x5c( + leaf_key, x5c, status=build_token_status_reference( + uri='https://issuer.example/status/x5c-list.jwt', index=3)) + with pytest.raises(EudiError): + verify_badge_sd_jwt(token, x5c_trust_anchors=[root], + require_status=True) + def test_untrusted_anchor_rejected(self): x5c, _root, leaf_key = _chain() unrelated_root = _chain()[1] diff --git a/wiki/Python-API-OB3.md b/wiki/Python-API-OB3.md index d0f729e..c962777 100644 --- a/wiki/Python-API-OB3.md +++ b/wiki/Python-API-OB3.md @@ -353,6 +353,29 @@ token = issue_badge_sd_jwt(credential, privkey_pem=priv_pem, vct=vct, The `vct#integrity` claim (a W3C SRI hash over the served bytes) pins the metadata, so a wallet fails closed on any tampering. `badge_type_metadata` / `type_metadata_document_bytes` / `type_metadata_integrity` are pure-Python (no `[eudi]` extra). For issuers who publish a did:web, `openbadges-publish -V 3` **emits this document for you**: set `[issuer] sd_jwt_vct` to a vct under `publish_url` and it is written into the webroot alongside `did.json` (byte-exact, `--check-live`-verified). The default `vct` stays the imsglobal purl, which ships no metadata. +### Advertising the badge over OpenID4VCI (`credential_configurations_supported`) + +An OpenID4VCI issuer publishes what it can issue in the `credential_configurations_supported` object of its Credential Issuer Metadata, and a wallet picks one by `credential_configuration_id`. The *content* of the Open Badge entry is Open Badges knowledge — the `vct`, the claim set, which claims are selectively disclosable — so build it rather than hand-copying it from the spec: + +```python +from openbadgeslib.ob3.eudi import badge_credential_configuration + +metadata = { # your document, your policy + 'credential_issuer': 'https://issuer.example', + 'credential_endpoint': 'https://issuer.example/credential', + 'nonce_endpoint': 'https://issuer.example/nonce', + 'authorization_servers': ['https://as.example'], + 'credential_configurations_supported': { + 'openbadge_sd_jwt_vc': badge_credential_configuration( + display=[{'name': 'Python 101', 'locale': 'en'}]), + }, +} +``` + +The entry's `vct` is the one `issue_badge_sd_jwt()` actually stamps and its `claims` are derived from the same `badge_type_metadata()` document you serve at that `vct` — the two artifacts cannot drift, which is the failure this prevents (a wallet that rejects the credential, or displays nothing). `credential_signing_alg_values_supported` defaults to `ES256`, the HAIP algorithm for EUDI wallets; `cryptographic_binding_methods_supported` to `jwk`, matching the `holder_jwk=` key binding above. Pure-Python: no `[eudi]` extra needed. + +**Out of scope on purpose:** the enclosing `/.well-known/openid-credential-issuer` document (endpoints, authorization servers, per-tenant display — deployment policy, and one document per tenant in a multi-tenant issuer), the Credential Offer, the Credential Response and the nonce store. The protocol endpoints are the application's; this library supplies the format knowledge. Note that OpenID4VCI 1.0 Final carries `claims`/`display` at the top level of the configuration, which is what this emits; the EU reference issuer nests them under `credential_metadata` — nest them yourself if you must match that deployment. + ### Verifying a third-party badge via eIDAS X.509 / EU Trusted Lists `verify_badge_sd_jwt` is **JWK-pinned** by default (`pubkey_pem=…`) — right when you hold the issuer's key. To verify a badge from an **unknown** issuer whose trust root is the eIDAS X.509 world, pass `x5c_trust_anchors` instead: a received badge whose issuer JWT carries an `x5c` chain is path-validated to those roots and **bound to `iss`** before its key is used (delegated to openvc-core's `verify_credential`; the JWK-pin path cannot do X.509 trust). Needs the `[eudi]` extra. @@ -372,7 +395,54 @@ verified = verify_badge_sd_jwt(received_badge, print('trusted issuer:', verified.issuer) # bound to the x5c-validated chain ``` -Exactly one of `pubkey_pem` / `x5c_trust_anchors` is required; the return value is the same `VerifiedSdJwt` either way. The trust-list walk is **fail-closed** (a Trusted List that can't be fetched or whose signature can't be verified contributes zero anchors) and **caller-pinned** (no implicit root); `walk_lotl`'s `select=` filters by qualified-service type. Status is not checked on either path — check it separately if you need it. +Exactly one of `pubkey_pem` / `x5c_trust_anchors` is required; the return value is the same `VerifiedSdJwt` either way. The trust-list walk is **fail-closed** (a Trusted List that can't be fetched or whose signature can't be verified contributes zero anchors) and **caller-pinned** (no implicit root); `walk_lotl`'s `select=` filters by qualified-service type. Credential revocation is checked on both paths when you pass a resolver — see the next section. (Certificate revocation, CRL/OCSP, is a different thing and is checked by neither layer; see [[Security Model]].) + +### Revoking an SD-JWT VC badge (IETF Token Status List) + +This track revokes through the **IETF OAuth Token Status List** — the JOSE status format SD-JWT VC uses — *not* the W3C Bitstring Status List of the VC-JWT / Data Integrity tracks. **Two formats, two lists:** they differ in bit order and compression, so a badge issued on both tracks needs two status entries pointing at two lists, not one list read two ways. Decide that before allocating indices; a credential carrying a W3C `credentialStatus` is refused by `issue_badge_sd_jwt` rather than silently mapped onto the wrong wire format. + +The list itself — packing, signing, publishing — is `openvc-core`'s (`openvc.status`); openbadgeslib carries the reference into the badge and checks it at verification. + +```python +from openbadgeslib.ob3.eudi import (issue_badge_sd_jwt, verify_badge_sd_jwt, + status_list_token_resolver) +from openvc.status.issue import (build_status_list_token, + build_token_status_reference) +from openvc.status.token_status_list import new_status_list, set_status + +LIST_URI = 'https://issuer.example/status/badges-1.jwt' + +# 1. Issue: the badge points at index 7 of that list. The `status` claim is +# ALWAYS disclosed — a holder must not be able to withhold it. +token = issue_badge_sd_jwt( + credential, privkey_pem=priv_pem, + status=build_token_status_reference(uri=LIST_URI, index=7)) + +# 2. Publish the list (serve these bytes at LIST_URI): +bits = new_status_list(131072) # all valid +list_token = build_status_list_token(signing_key=key, uri=LIST_URI, + status_list=bits) + +# 3. Verify with the status check wired in: +resolve = status_list_token_resolver(pubkey_pem=pub_pem) # fetch + verify +result = verify_badge_sd_jwt(token, pubkey_pem=pub_pem, + require_status=True, + resolve_status_list_token=resolve) + +# 4. Revoke: flip the bit, re-sign, re-publish. The same badge now fails. +set_status(bits, 7, 1) # 1 = INVALID (revoked) +``` + +`resolve_status_list_token` is any `uri -> claims` callable; `status_list_token_resolver` builds the safe default — it fetches over HTTPS (SSRF-guarded, size-capped) and verifies the list token's signature with a key you pin, including the IETF anti-swap check that the token's `sub` equals the URL it came from. Without it a list served at one URL could be replayed at another to un-revoke a badge. + +The two knobs are independent, and both fail closed: + +| | no resolver | resolver supplied | +|---|---|---| +| `require_status=False` (default) | status not checked; badge verifies | status checked; revoked/suspended → `EudiError` | +| `require_status=True` | badge that *declares* a status → `EudiError` | same as above | + +`require_status` is about a status that cannot be **checked**, not about demanding every badge carry one: a badge issued without `status=` verifies under either setting and is simply not revocable. An unreadable list — fetch failure, wrong signing key, expired token, wrong `sub` — always raises rather than being read as "not revoked". ## Presenting a Data Integrity badge over OpenID4VP (`ldp_vc`) diff --git a/wiki/Security-Model.md b/wiki/Security-Model.md index 54d7186..938ac69 100644 --- a/wiki/Security-Model.md +++ b/wiki/Security-Model.md @@ -183,7 +183,9 @@ The EUDI SD-JWT VC track (`openbadgeslib.ob3.eudi`, the `[eudi]` extra) can anch - **openvc‑core owns the chain verdict:** path validation (signatures, the leaf/intermediate **temporal validity windows**, name chaining, `basicConstraints`), the leaf `SAN` ↔ `iss` binding, and a P‑256 leaf key. openbadgeslib re‑asserts these at the boundary with dedicated tests (`tests/test_ob3_eudi_x5c.py`) that run against **both** the pinned floor and the latest openvc‑core release in CI, so a drift in that external behaviour turns the build red. - **Certificate revocation (CRL / OCSP) is NOT checked — by either layer.** `cryptography`'s path validation does not consult CRL Distribution Points or perform OCSP, and openvc‑core adds none. **A leaf that has been revoked but is still inside its validity window, with an otherwise‑valid chain, will verify.** A deployment that must honour revocation has to obtain it out of band (for example the EU Trusted List's own revocation signalling) — do not assume this call applies it. This gap is pinned by a regression test, so a future openvc‑core that starts enforcing revocation is caught by the drift job and prompts a contract/doc update. -Separately, an SD‑JWT VC badge is **irrevocable at the credential level**: `issue_badge_sd_jwt` rejects a credential that carries a `credentialStatus` rather than silently dropping it (SD‑JWT VC status carriage is not wired yet), and both verify paths set `require_status=False`. Revocation of an SD‑JWT VC badge therefore currently rests entirely on the (unchecked, see above) certificate layer. +Separately — and not to be confused with the certificate revocation above — an SD‑JWT VC badge **can** be revoked at the credential level, through the **IETF OAuth Token Status List** (#270). The issuer passes `issue_badge_sd_jwt(..., status=build_token_status_reference(uri=…, index=…))`, which is carried as an **always‑disclosed** `status` claim: a holder able to withhold the pointer could present a revoked badge as an unrevokable one, so selective disclosure never covers it. A verifier opts in with `verify_badge_sd_jwt(..., resolve_status_list_token=…)` — checked on **both** trust paths — and everything about not knowing the status fails closed: an unresolvable, wrongly‑signed, expired or URL‑swapped list raises rather than reading as "not revoked", and `require_status=True` turns a status that cannot be checked into a failure. See [[Python API OB3]] for the API. + +Two limits an operator must know. First, **status format ≠ status format**: this track's IETF Token Status List and the W3C Bitstring Status List used by the VC‑JWT / Data Integrity tracks are different wire formats over different lists, so a credential carrying a `credentialStatus` is still refused at SD‑JWT issuance rather than silently mapped, and a badge issued on both tracks needs an index in each list. Second, the status list is only as trustworthy as the key you pin for it: `status_list_token_resolver(pubkey_pem=…)` verifies the list token's signature and enforces the IETF anti‑swap check (`sub` = the URL it was fetched from), which is what stops a compromised status host from un‑revoking badges. ## What the signature binds — the assertion, not the image