A dependency-light, HSM-friendly Verifiable Credentials core for Python: sign and verify W3C VCs in the three mainstream proof formats, resolve issuer keys, check revocation, and verify wallet presentations — fail-closed by default, with private keys that never have to enter the process.
| Capability | What is covered | Spec |
|---|---|---|
| Sign & verify | VC-JWT (ES256 / ES384 / EdDSA) |
VC-JOSE-COSE |
| SD-JWT VC — selective disclosure, Key Binding, Type Metadata | SD-JWT VC | |
Data Integrity — eddsa-rdfc-2022, ecdsa-rdfc-2019, eddsa-jcs-2022 / ecdsa-jcs-2019 (stdlib JCS, no pyld), and selective-disclosure ecdsa-sd-2023 |
vc-di-eddsa / vc-di-ecdsa | |
| Verify presentations | VP-JWT, Data Integrity challenge/domain, and stateless OpenID4VP 1.0 vp_token — incl. HAIP direct_post.jwt JWE-encrypted responses and the W3C Digital Credentials API (origin-bound) |
OpenID4VP / HAIP / DC API |
| EUDI relying-party access certificate (WRPAC) — the X.509 identity of the requester, validated to ACA anchors | ETSI TS 119 411-8 | |
| EUDI relying-party registration certificate (WRPRC) — the signed JWT/CWT carrying the requester's registered entitlements, cross-checked against its WRPAC and against what the request actually asks for | ETSI TS 119 475 | |
| Resolve issuer keys | did:key, did:jwk, did:web, did:webvh (verifiable-history log) (+ did:ebsi via plugin), /.well-known/jwt-vc-issuer, X.509 x5c chains with SAN issuer binding |
DID |
| Revocation | Bitstring Status List and Token Status List — check and issue | W3C / IETF |
| Trust anchors | Caller-pinned X.509 anchors, EU Trusted Lists (LOTL → national TL), EBSI Trusted Issuers Registry (read-only plugin) | ETSI TS 119 612 / EBSI |
| Keys | The SigningKey protocol — an HSM / KMS / Vault backend is a drop-in; ES256 signatures are raw JOSE R‖S, never DER |
— |
The PyPI distribution is openvc-core (the import package stays openvc):
pip install openvc-coreThe core needs only cryptography and pyjwt. Everything heavier is an extra:
| Extra | Adds | Pulls in |
|---|---|---|
openvc-core[data-integrity] |
RDF-canonicalized suites (eddsa-rdfc-2022, ecdsa-rdfc-2019, ecdsa-sd-2023) |
pyld |
openvc-core[ebsi] |
the EBSI registry client | httpx |
openvc-core[schema] |
credentialSchema (W3C VC JSON Schema) validation |
jsonschema |
openvc-core[trustlist] |
XAdES signature verification for EU Trusted Lists | signxml |
openvc-core[all] |
everything above + the dev tools |
Issue a VC-JWT and verify it with the one-call pipeline. verify_credential
detects the format (VC-JWT / SD-JWT VC / Data Integrity / enveloped), resolves
the issuer key, verifies the proof, and applies policy — types, audience, and
fail-closed status:
from cryptography.hazmat.primitives.asymmetric import ed25519
from openvc import VerificationPolicy, verify_credential
from openvc.keys import Ed25519SigningKey
from openvc.multibase import encode_multibase
from openvc.proof.vc_jwt import VcJwtProofSuite
# An issuer key addressed by did:key, so the whole flow runs offline.
private_key = ed25519.Ed25519PrivateKey.generate()
public_raw = Ed25519SigningKey(private_key, kid="_").public_key_raw()
mb = encode_multibase(bytes([0xED, 0x01]) + public_raw) # multicodec ed25519-pub
issuer = Ed25519SigningKey(private_key, kid=f"did:key:{mb}#{mb}")
token = VcJwtProofSuite().sign({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"id": "urn:uuid:2f3a-example",
"type": ["VerifiableCredential", "ExampleCredential"],
"issuer": f"did:key:{mb}",
"credentialSubject": {"id": "did:example:alice", "name": "Ada Lovelace"},
}, signing_key=issuer)
result = verify_credential(
token, policy=VerificationPolicy(expected_types=["ExampleCredential"]))
print(result.format, result.issuer, result.subject)Selective disclosure with SD-JWT VC — issue, present with a Key Binding JWT,
verify; the holder proves possession of the cnf key and the verifier sees
only what was disclosed:
from openvc.keys import Ed25519SigningKey
from openvc.proof.sd_jwt import SdJwtVcProofSuite
issuer = Ed25519SigningKey.generate(kid="https://issuer.example#key-1")
holder = Ed25519SigningKey.generate(kid="holder-key-1")
suite = SdJwtVcProofSuite()
sd_jwt = suite.issue(
{"iss": "https://issuer.example", "given_name": "Ada", "age": 36},
signing_key=issuer, disclosable=["given_name", "age"],
holder_jwk=holder.public_jwk(), vct="https://credentials.example/identity")
presentation = suite.create_presentation(
sd_jwt, holder_key=holder, audience="https://verifier.example", nonce="n-123")
result = suite.verify(
presentation, public_key_jwk=issuer.public_jwk(),
audience="https://verifier.example", nonce="n-123", require_key_binding=True,
expected_vct="https://credentials.example/identity")
print(result.claims["given_name"], result.key_bound)Every flow — Data Integrity proofs, VP-JWT and OpenID4VP presentations, status
lists, remote HSM signing, EU Trusted Lists, EBSI — has a guide in the
wiki and a runnable script in
examples/.
- HSM-first. Signing goes through the
SigningKeyprotocol (alg/kid/sign), so a PKCS#11, AWS KMS, or Vault Transit backend drops in and the private key never enters the process. ES256 signatures are the correct raw JOSER‖Sform — the classic reason a locally-produced token fails elsewhere. - Fail-closed by construction. The
{ES256, ES384, EdDSA, Ed25519}allow-list runs before any crypto (alg:none, RS*, HS* never reach a verifier); a declared credential status without a resolver rejects; an unparseable timestamp rejects; the JWT envelope is reconciled with the embedded credential. - Post-quantum ready (experimental). ML-DSA (RFC 9964,
ML-DSA-44/65/87) signs and verifies VC-JWT / SD-JWT VC behind an explicit opt-in (allow_pq=True) and the[pq]extra — first-mover space; no maintained Python VC library signs ML-DSA today. Never a default trust path; the allow-list above is unchanged unless you opt in. - SSRF-guarded network. Every issuer-named URL (
did:web, well-known, status lists, schemas) goes through an https-only fetch that blocks private/loopback/link-local ranges, refuses redirects, and pins the connection to the validated IP (no DNS rebinding). - Dependency-light. The core imports
cryptographyandpyjwt, nothing else; JSON canonicalization (RFC 8785) and theecdsa-sd-2023CBOR codec are hand-rolled on the stdlib, andpyld/httpxstay behind extras. - Conformance pinned by real vectors.
eddsa-rdfc-2022reproduces the official W3C test vector byte-for-byte;ecdsa-rdfc-2019/ecdsa-sd-2023verify the official vc-di-ecdsa vectors and match their intermediates; ISO 18013-5mso_mdocverifies the Annex D referenceDeviceResponse; the EBSI client is verified against recorded pilot responses. Golden fixtures are the drift alarm. Beyond them, a test-only VC-API shim runs openvc through the official W3C suites (vc-data-model-2.0, vc-di-eddsa, vc-di-ecdsa, bitstring-status-list) for third-party conformance reports.
- Manual (wiki) — installation, a guide per proof format, presentations & OpenID4VP, issuer-key resolution, status lists, trust (EU Trusted Lists, EBSI), HSM integration, the security model, and the versioning contract.
- API reference — generated from the docstrings, per module.
examples/— ten runnable, offline scripts covering every flow (they run in CI, so they cannot rot).
openvc is the generic VC machinery a badge issuer, an EBSI verifier, or a
EUDI wallet backend builds on — intentionally not an Open Badges library, a
wallet, or a node operator. EBSI support is read-only (resolve did:ebsi,
read the trust registries); onboarding/writing is out of scope. The
openvc_ebsi plugin depends on openvc, never the reverse.
- Changelog — every release, with the spec/security reasoning
- Roadmap
- Versioning & deprecation policy
- Contributing — dev setup, checks, commit convention
- Security policy and the threat model
pip install -e ".[all]" # from a checkout
pytest # offline: deterministic, no network
OPENVC_EBSI_LIVE=1 pytest # + the opt-in live EBSI smoke testLGPL-3.0-or-later. Copyright © 2026 Luis González Fernández. See COPYING.LESSER and COPYING.