feat(eudi): revocable SD-JWT VC badges, the OpenID4VCI configuration entry, and the cryptography exception contract - #273
Merged
Conversation
SD-JWT VC was the only one of the four tracks whose badges could not be revoked: issuance REFUSED a credential carrying a credentialStatus (#226, the right call while no interoperable JOSE-track revocation existed) and both verify paths hard-set require_status=False. For an issuer whose product promises revocation that is not a caveat but a hard stop — every badge sent to a wallet is one it can never withdraw, including after a mistaken issuance or an erasure request. The mechanism now exists and the delegate speaks it, so #245's gate was resolved as go (gates 2 and 3 met; gate 1, the RFC, passed deliberately on IESG approval plus openvc-core's byte-pinned conformance suite, with #127 owning the draft->RFC citation swap). Issuance takes status=build_token_status_reference(uri=…, index=…) and emits it as an ALWAYS-DISCLOSED `status` claim — excluded from the disclosable set even when the caller asks for it, because a holder able to withhold the pointer could present a revoked badge as an unrevokable one. Verification takes require_status / resolve_status_list_token on both trust paths: the x5c path hands them to verify_credential (which owns status), the pinned-key path calls check_token_status itself, since the suite-level verify has no status stage. Revoked or suspended raises EudiError; so does every way of not knowing — an unresolvable, wrongly-signed, expired or URL-swapped list is never read as "not revoked". status_list_token_resolver() builds the safe default resolver, pinning the list signer's key and enforcing the IETF anti-swap sub check. Nothing about the encoding is reimplemented here: packing, DEFLATE, multi-bit statuses and the token signature are openvc-core's, and the [eudi] floor moves to >=1.23 accordingly. Two formats, two lists: a W3C credentialStatus (Bitstring) is still refused at SD-JWT issuance rather than mapped, since it points at a list an SD-JWT verifier cannot read — a badge issued on both tracks needs an index in each. That is #226's reasoning, which does not expire. Closes #270. Refs #226, #245, #248.
An OpenID4VCI issuer advertises what it can issue in credential_configurations_supported, and a wallet picks one by credential_configuration_id. The content of that entry for an Open Badge issued as SD-JWT VC is entirely Open Badges knowledge — the vct, the claim names the badge maps to, which of them are selectively disclosable — so every downstream issuer was hand-writing it from the spec, and the two facts it most easily gets wrong are ours to know: a drifting copy of the vct or of the disclosure rule produces a wallet that either rejects the credential or silently displays nothing. badge_credential_configuration() returns that entry — format dc+sd-jwt, vct, signing/proof algorithms (ES256, the HAIP floor), cryptographic_binding_methods_supported, claims and display — with the claim set derived from badge_type_metadata(), so the Type Metadata document an issuer serves at the vct and the configuration it advertises cannot drift from each other or from what issue_badge_sd_jwt() stamps. Pure dict building: no state, no I/O, no protocol, no [eudi] extra. Out of scope on purpose, the same boundary openvc-core's ADR-0007 draws one layer down: the /.well-known/openid-credential-issuer document (endpoints, authorization servers, per-tenant display — deployment policy, one document per tenant), the Credential Offer, the Credential Response and the nonce store. Layout follows OpenID4VCI 1.0 Final (claims/display at the top level of the configuration); the EU reference issuer nests them under credential_metadata, which the docs note — it is a plain dict. Closes #271. Refs #270, #272.
The cryptography floor was >=42, stale against a change that alters observable behaviour: v47 made loading a key of an unsupported algorithm raise UnsupportedAlgorithm — which is NOT a ValueError — where earlier versions raised ValueError. Below the surface the direct loaders caught neither: KeyRSA/KeyECC.read_private_key/read_public_key and the Ed25519 loaders called cryptography unguarded, so a corrupt or unsupported key PEM escaped as a raw crypto traceback. (Badge.__init__ was already guarded with a broad except — these callers were not, the same inconsistency class as #59/#60/#65/#82.) All PEM loading now goes through one guarded pair of helpers that map both contracts onto PrivateKeyReadError / PublicKeyReadError, keeping the original exception as __cause__; detect_key_type and ec_curve_from_pem stay total (UnknownKeyType / None). KeyRSA. generate_keypair maps cryptography v48's 1024-bit RSA floor to GenPrivateKeyError instead of a raw ValueError. The floor moves to >=45 — openvc-core's own core pin, so an install taking the [eudi]/[ldp-sd] extras resolves one range rather than two — and deliberately NOT to >=47: both contracts are handled and both are tested (parametrised over ValueError and UnsupportedAlgorithm), so requiring an April-2026 release would buy nothing. environment.yml follows. Closes #244. Refs #170.
`test (windows, py3.12)` has failed on every master run since d4455d2 — the commit that added the provenance gate (#266) — and the files were never the problem. Without a .gitattributes entry git checks the bundled JSON-LD contexts out with CRLF on a Windows clone, so the bytes on disk are not the bytes whose SHA-256 is recorded in ob3/contexts/__init__.py. Confirmed arithmetically: hashing the LF file with \n replaced by \r\n reproduces the exact digest the Windows runner reported. That is not merely a red leg. The digests are the control that makes the fail-closed context allowlist meaningful — the bundled document decides what a Data Integrity proof covers — and on a Windows git checkout that control cannot be evaluated at all, which is the property #266 set out to establish. It also blocks releases: the publish job is `needs: test`, so a pushed tag will not publish to PyPI while any leg is red. Mark them -text so git never rewrites their line endings on any platform, and gate the attribute itself so it cannot quietly disappear the way the digest comment quietly became a lie. Wheel and sdist installs were never affected — the build applies no EOL conversion — and RDF canonicalization was not either, since CRLF lands in insignificant JSON whitespace. Closes #274. Refs #266.
luisgf
force-pushed
the
feat/eudi-token-status-list
branch
from
July 27, 2026 10:45
a293fe3 to
02fc1a2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three independent pieces of the post-v4 roadmap, each in its own commit.
feat(eudi)— revocable SD-JWT VC badges via IETF Token Status List (#270)SD-JWT VC was the only one of the four tracks whose badges could not be revoked:
issuance refused a credential carrying a
credentialStatus(#226 — the right callwhile no interoperable JOSE-track revocation existed) and both verify paths hard-set
require_status=False. For an issuer whose product promises revocation that is a hardstop, not a caveat: every badge sent to a wallet is one it can never withdraw, including
after a mistaken issuance or an erasure request.
issue_badge_sd_jwt(..., status=build_token_status_reference(uri=…, index=…))emits an always-disclosed
statusclaim — excluded from the disclosable set evenwhen the caller asks for it, because a holder able to withhold the pointer could present
a revoked badge as an unrevokable one.
require_status/resolve_status_list_tokenon both trust paths.The x5c path hands them to
verify_credential(which owns status); the pinned-key pathcalls
check_token_statusitself, since the suite-level verify has no status stage.Revoked or suspended raises
EudiError— and so does every way of not knowing: anunresolvable, wrongly-signed, expired or URL-swapped list is never read as "not revoked".
status_list_token_resolver(pubkey_pem=…)builds the safe default resolver: HTTPS fetch(SSRF-guarded, size-capped) plus openvc-core's
verify_status_list_token, including theIETF anti-swap
subcheck.[eudi]floor moves to >=1.23.Gate: #245 was resolved as go — gates 2 and 3 met, gate 1 (the RFC) passed
deliberately on IESG approval plus a byte-pinned conformance suite, with #127 owning the
draft→RFC citation swap.
Two formats, two lists: a W3C
credentialStatus(Bitstring) is still refused atSD-JWT issuance rather than mapped — it points at a list an SD-JWT verifier cannot read,
so a badge issued on both tracks needs an index in each. #226's reasoning does not expire.
feat(eudi)— the OB 3.0 credential configuration entry for OpenID4VCI (#271)badge_credential_configuration()returns thecredential_configurations_supportedentry a wallet reads to request the badge, with the claim set derived from
badge_type_metadata()— so the Type Metadata document an issuer serves at thevctand the configuration it advertises cannot drift from each other, or from what
issue_badge_sd_jwt()stamps. Pure dict building: no state, no I/O, no protocol, no[eudi]extra.Out of scope on purpose (deployment policy, one per tenant): the enclosing
/.well-known/openid-credential-issuerdocument, the Credential Offer, the CredentialResponse, the nonce store.
fix(keys)— bothcryptographyexception contracts map onto the taxonomy (#244)The floor was stale at
>=42, but the real gap was underneath it:KeyRSA/KeyECC.read_private_key/read_public_keyand the Ed25519 loaders calledcryptographyunguarded, so a corrupt or unsupported key PEM escaped as a rawtraceback. v47 changed the contract on top of that — an unsupported algorithm now raises
UnsupportedAlgorithm, which is not aValueError.All PEM loading now goes through one guarded pair of helpers mapping both contracts onto
PrivateKeyReadError/PublicKeyReadError(original kept as__cause__);detect_key_type/ec_curve_from_pemstay total;KeyRSA.generate_keypairmaps v48's1024-bit floor to
GenPrivateKeyError. Floor moves to >=45 (openvc-core's own pin, sothe extras resolve one range) and deliberately not to >=47 — both contracts are
handled and tested, so requiring an April-2026 release would buy nothing.
Verification
pytest: 1389 passed, 1 skipped (was 1346 on master).flake8,mypyandgitlintclean.passing respectively, so the history bisects cleanly.
Decisions recorded in the issues
The departures from the issue texts are written up in comments on #270 (the W3C-status
refusal as the case that stays unrepresentable; the status check living in different
places on the two paths;
require_statusdefaulting off; the resolver factory; the TypeMetadata document deliberately left unchanged, since declaring
statusthere wouldinvalidate every published
vct#integritypin) and on #271 (deriving claims from the TypeMetadata document; emitting the OpenID4VCI 1.0 Final layout rather than the EU reference
issuer's newer
credential_metadatanesting).Closes #270
Closes #271
Closes #244
Refs #226, #245, #248, #272.