Skip to content

feat(eudi): revocable SD-JWT VC badges, the OpenID4VCI configuration entry, and the cryptography exception contract - #273

Merged
luisgf merged 4 commits into
masterfrom
feat/eudi-token-status-list
Jul 27, 2026
Merged

feat(eudi): revocable SD-JWT VC badges, the OpenID4VCI configuration entry, and the cryptography exception contract#273
luisgf merged 4 commits into
masterfrom
feat/eudi-token-status-list

Conversation

@luisgf

@luisgf luisgf commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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 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 a hard
stop, not a caveat: every badge sent to a wallet is one it can never withdraw, including
after a mistaken issuance or an erasure request.

  • Issuance: issue_badge_sd_jwt(..., status=build_token_status_reference(uri=…, index=…))
    emits 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: 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 — and 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(pubkey_pem=…) builds the safe default resolver: HTTPS fetch
    (SSRF-guarded, size-capped) plus openvc-core's verify_status_list_token, including the
    IETF anti-swap sub check.
  • Encoding, packing and signing stay openvc-core's; the [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 at
SD-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 the credential_configurations_supported
entry 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 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 (deployment policy, one per tenant): the enclosing
/.well-known/openid-credential-issuer document, the Credential Offer, the Credential
Response, the nonce store.

fix(keys) — both cryptography exception 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_key and the Ed25519 loaders called
cryptography unguarded, so a corrupt or unsupported key PEM escaped as a raw
traceback. v47 changed the contract on top of that — an unsupported algorithm now raises
UnsupportedAlgorithm, which is not a ValueError.

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_pem stay total; KeyRSA.generate_keypair maps v48's
1024-bit floor to GenPrivateKeyError. Floor moves to >=45 (openvc-core's own pin, so
the 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, mypy and
    gitlint clean.
  • Each commit was checked out into a temporary tree and run on its own: 1366 and 1377
    passing respectively, so the history bisects cleanly.
  • Implemented and tested against openvc-core 1.23.1, the current release.

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_status defaulting off; the resolver factory; the Type
Metadata document deliberately left unchanged, since declaring status there would
invalidate every published vct#integrity pin) and on #271 (deriving claims from the Type
Metadata document; emitting the OpenID4VCI 1.0 Final layout rather than the EU reference
issuer's newer credential_metadata nesting).

Closes #270
Closes #271
Closes #244

Refs #226, #245, #248, #272.

luisgf added 4 commits July 27, 2026 10:34
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
luisgf force-pushed the feat/eudi-token-status-list branch from a293fe3 to 02fc1a2 Compare July 27, 2026 10:45
@luisgf
luisgf merged commit 8da6a45 into master Jul 27, 2026
14 of 15 checks passed
@luisgf
luisgf deleted the feat/eudi-token-status-list branch July 27, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant