SD-JWT VC + OpenID4VC
A pure-Dart library for the holder half of an EU Digital Identity wallet — receive a credential, hold and trust it, and present only the claims a verifier asks for. It implements SD-JWT VC, OpenID4VCI (issuance) and OpenID4VP (presentation), and stays agnostic about keys and the network so a hardware key drops straight in. The protocol companion to Attested Secure Keys.
The brief
An EUDI wallet has to do three things with a credential: receive it from an issuer, hold it and prove it’s genuine, and present a minimal slice of it to a verifier — all over the EU’s chosen standards (SD-JWT VC, OpenID4VCI, OpenID4VP, eIDAS 2). That’s the holder role: the wallet’s half of the protocol.
This library is exactly that half, in pure Dart — no Flutter dependency, no key backend, no HTTP client baked in. It began as the protocol layer for an EUDI-wallet proof-of-concept, alongside attested_secure_keys (the hardware-key layer), then generalised so any Dart wallet can reuse it.
The gap it fills
The mature OpenID4VC SDKs live in Kotlin, Swift and Rust. On pub.dev there was no comprehensive holder library: openid_client does classic OIDC login, not verifiable credentials; the JOSE pieces are partial and stop well short of SD-JWT VC + OID4VCI + OID4VP.
So this is a thin, reusable package that speaks the whole holder flow — and it’s deliberately unopinionated about the two things every wallet does differently: the key (you inject an Es256Signer) and the network (you inject an Oid4vcHttp). Everything in between is pure logic you can test without hardware and without a server.
How it’s built
Two strata: transport (the OID4VCI / OID4VP clients) over format (the SD-JWT VC codec). The split is on purpose — the codec knows nothing about HTTP, and the transport knows nothing about SD-JWT, so a future mdoc codec reuses the same issuance/presentation plumbing. The one crypto file (pointycastle) is never exported.
The two flows: issuance and presentation
Issuance pulls a credential from an issuer and binds it to the holder’s key. Presentation answers a verifier’s query with a minimal, freshly-signed proof. The wallet sits in the middle; the same hardware key signs the proof-of-possession at issuance and the Key-Binding JWT at presentation.
Selective disclosure — reveal only what’s asked
This is the point of SD-JWT. The wallet doesn’t hand over plaintext; it includes the disclosures for the requested claims (nested paths included) next to the issuer-signed JWT, omits everything else, and binds it all to the verifier with a Key-Binding JWT. The verifier recomputes the digests, checks the issuer’s seal, and learns nothing it didn’t ask for.
What it does
A small public surface, one client per protocol leg plus the codec:
- Oid4vciClient
- The issuance dance (OpenID4VCI, pre-authorized_code + tx_code): parse the offer, fetch issuer metadata, request the token, build the holder proof-of-possession, and pull back the SD-JWT VC.
- SdJwt · SdJwtVc
- The SD-JWT VC codec: parse the compact form, resolveClaims (nested objects + arrays), verifyIssuer, and present — selective disclosure plus a Key-Binding JWT.
- IssuerTrust
- How the issuer key is resolved and trusted: signature-only, full x5cChain validation to a caller-supplied anchor (the EU Trusted List), or jwt-vc-issuer metadata.
- Oid4vpClient
- The presentation flow (OpenID4VP + DCQL, the Digital Credentials Query Language): fetch and authenticate the request, match a held credential, and submit — including the encrypted direct_post.jwt response mode.
- StatusListResolver
- Resolve a credential's revocation status from its IETF Token Status List — fetch, optional issuer-signature verify, zlib-inflate, read the status bit.
- Es256Signer · Oid4vcHttp
- The two injected seams. Nothing else in the library touches a private key or the network, so it stays testable without hardware and without a server.
// The two seams you inject: the holder key (hardware-backed,
// e.g. via attested_secure_keys) and an HTTP client. Everything
// else is pure, deterministic logic.
final vci = Oid4vciClient(DefaultOid4vcHttp());
final vp = Oid4vpClient(DefaultOid4vcHttp());
// 1 · Receive a credential (OpenID4VCI, pre-authorized flow).
// The proof-of-possession is signed inside the secure hardware.
final compact = await vci.redeemOffer(
offerUriOrJson: offerLink, // openid-credential-offer://…
txCode: pin,
signer: holderKey,
);
// 2 · Hold it, and trust the issuer (SD-JWT VC + X.509 chain).
final vc = SdJwt.parse(compact);
await vc.verifyIssuer(
IssuerTrust.x5cChain(trustAnchors: lotlAnchors),
enforceValidity: true,
);
// 3 · Present ONLY what a verifier asks for (OpenID4VP + DCQL).
final req = await vp.fetchRequest(requestUri);
final match = vp.match(req, [vc])!; // has the requested claims?
await vp.present(req: req, match: match, signer: holderKey);- Transport
- https-or-loopback only
- JOSE
- alg / typ asserted before key work
- Parsing
- depth + duplicate-digest guards
- Issuer
- X.509 chain → trust anchor
- SD-JWT
- RFC 9901 disclosure vectors
- Status List
- IETF draft bitstring vectors
- JWE / KDF
- RFC 7518 App. C vector
- Parsers
- fuzz-tested, never crash
Design decisions & honest trade-offs
No issuer or verifier server logic, no credential storage, no Relying-Party trust policy — those belong to the app and the backend. The library does the wallet’s half and returns/accepts plain strings.
It takes an Es256Signer and an Oid4vcHttp and never imports a key backend or a specific HTTP client. A hardware key plugs in as one small adaptor; tests use a software signer.
The SD-JWT codec is independent of the OID4VCI/VP transport, so a future ISO 18013 mdoc codec reuses the same issuance and presentation plumbing rather than forking it.
Time comes from an injected Clock, salts from an injected generator — no ambient DateTime.now() in a signing path. Every token is byte-reproducible, which is how the suite holds 100% line coverage.
Reveal exactly the requested claims — including nested paths like place_of_birth.locality — omit the rest, and bind the presentation to the verifier’s nonce and audience with a Key-Binding JWT. Matching refuses a credential that lacks a requested claim instead of over-promising.
The library ships the chain-validation mechanism (IssuerTrust.x5cChain), but the anchors — the EU List of Trusted Lists — are supplied by the integrator. Certificate revocation (CRL/OCSP) and RP trust policy stay out, on purpose.
Proven against the real thing
Unit tests prove the format; the real test is interop. The full loop was run on a device against the EU reference wallet stack — a genuine PID (Person Identification Data) credential issued, issuer-trusted via its X.509 chain, and presented back to the reference verifier. That live run surfaced two things the drafts leave open: the verifier’s encrypted direct_post.jwt response mode (an ECDH-ES + AES-GCM JWE) and nested-claim DCQL requests — both now implemented and shipped.
On top of interop, the wire format is pinned to published spec vectors: the IETF SD-JWT disclosure-digest examples, the Token Status List bitstrings, and the RFC 7518 Appendix C key-derivation vector. The parsers are fuzz-tested so hostile input is always rejected, never a crash.
Status
Published to pub.dev as 0.1.2 — the protocol companion to attested_secure_keys; together they form the holder half of a working EUDI wallet. Full line coverage, clean under a strict lint set, spec-vector conformance, fuzz-tested parsers, and CI that pins the minimum SDK so downstream wallets resolve it with no overrides. Open source under Apache-2.0.