← Back to portfolio

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.

Standards
SD-JWT VC · OID4VCI/VP
Role
Holder / wallet
Language
Pure Dart
Coverage
100% lines
Role
Solo — protocol design, JOSE/crypto, and the full test suite
Status
v0.1.2 — live-verified against the EU reference wallet
License
Apache-2.0 · open source

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.

Layered architecture of the sdjwt_oid4vc libraryYour Flutter wallet appEs256Signerhardware holder key ⟶ injectOid4vcHttptransport ⟶ injectsdjwt_oid4vcTRANSPORTOid4vciClientissuance — OpenID4VCIOid4vpClientpresentation — OpenID4VP + DCQLFORMATSD-JWT VC codecparse · resolveClaims · present (KB-JWT) · IssuerTrust · StatusListResolvercore (not exported): ES256 verify · JWE (ECDH-ES) · X.509 chain — pointycastleformat is independent of transport → a future mdoc codec reuses the same clients
Two strata: transport (the OID4VCI / OID4VP clients) over format (the SD-JWT VC codec). Your app injects the only two things a wallet does differently — the holder key (Es256Signer) and the HTTP client (Oid4vcHttp). The codec is independent of the transport, so a future mdoc codec reuses the same issuance/presentation clients. The pointycastle crypto lives in one file and 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.

Issuance and presentation sequence across issuer, wallet and verifierIssuerWalletholder (this library)VerifierISSUANCE — OpenID4VCI① credential offer (+ tx_code out-of-band)② token request (pre-auth code + tx_code)③ proof-of-possession (holder key)④ SD-JWT VCheld + issuer-trustedPRESENTATION — OpenID4VP⑤ request (DCQL query)match · select disclosuressign Key-Binding JWT⑥ vp_token (direct_post · direct_post.jwt)verify issuer seal + KB-JWTone hardware key signs both the proof-of-possession and the Key-Binding JWT
The wallet is the holder in the middle. Issuance (OpenID4VCI, pre-authorized flow): redeem an offer with a tx_code, sign a proof-of-possession with the holder key, receive the SD-JWT VC. Presentation (OpenID4VP): match the verifier's DCQL query, select only the requested disclosures, sign a Key-Binding JWT, and submit the vp_token. The same hardware key signs both the proof and the KB-JWT.

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.

Selective disclosure: reveal only the requested claimsHeld credential (SD-JWT VC)issuer-signed · claims blindedgiven_namefamily_nameplace_of_birth.localityplace_of_birth.countryage_equal_or_over.18nationalities[]6 disclosures held on deviceVerifier asksDCQL claim paths• place_of_birth.locality• age_equal_or_over.18vp_token sentonly what was askedissuer-signed JWT✓ place_of_birth.locality✓ age_equal_or_over.18Key-Binding JWT (holder key)hidden: family_name ·country · nationalities · given_namebound to nonce + audienceVerifier reconstructs 2 claims — verifies issuer seal + KB-JWTlearns nothing it didn’t ask for; the rest never left the device
The held credential carries every claim, but each is blinded — only a digest is in the issuer-signed JWT; the holder keeps the disclosures. The verifier asks for two (one nested). The wallet sends the issuer JWT plus only those two disclosures and a Key-Binding JWT; family_name, country and nationalities never leave the device. The verifier recomputes the digests, checks the issuer signature and the KB-JWT, and reconstructs exactly the two claims it asked 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);
Security hardening
Transport
https-or-loopback only
JOSE
alg / typ asserted before key work
Parsing
depth + duplicate-digest guards
Issuer
X.509 chain → trust anchor
Spec conformance
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

Holder role only

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.

Key- and HTTP-agnostic

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.

Format over transport

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.

Deterministic by construction

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.

Only the requested claims

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.

Trust data is the app’s job

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.

Stack

DartSD-JWT VCOpenID4VCIOpenID4VPDCQLJOSE / JWSES256 · P-256Key-Binding JWTJWE · ECDH-ES · AES-GCMToken Status ListX.509 chain validationpointycastle · asn1lib