← Back to portfolio

A working EUDI wallet

The capstone that proves the other two: a real EU Digital Identity wallet assembled from attested_secure_keys (hardware keys) and sdjwt_oid4vc (the holder protocol), joined by a 72-line adaptor. It runs the whole issue → hold → present journey — offline against a mock backend, and live against the EU reference issuer and verifier.

Built from
2 libraries + glue
Journey
Issue · Hold · Present
Backends
Mock + live EUDI
Proof
On-device E2E test
Role
Solo — the app, both libraries, and the adaptor between them
Status
Reference / demo build — device-verified, Mode A & live EUDI
Verification
On-device integration test through the real hardware-key plugin
Screen recording
the full issue → hold → present journey — Mode A & live EUDI

Why an app, not just libraries

The two library case studies each describe a half of an EUDI wallet: one mints and attests the holder’s key in secure hardware, the other speaks the protocol to receive, hold and present credentials. A reasonable question is whether the two actually meet — or whether they only look composable on paper.

This is the proof: a running Flutter wallet that wires both together and walks the full holder journey. It’s deliberately a reference / demo build, not a shipping product — its job is to show the seams line up, end to end, on a real device and against the real EU infrastructure.

The two libraries meet in 72 lines

sdjwt_oid4vc never imports a key backend — it asks an Es256Signer for a public JWK, an ES256 signature, and an optional key attestation. A HwKey from attested_secure_keys can answer all three. So the entire integration is one small adaptor that implements that interface from secure hardware — the design bet of both libraries (inject the key, inject the network) collected in one file.

How the two libraries compose into the walletEUDI wallet app (Flutter)Home · Import · Present — over one WalletControllersdjwt_oid4vcholder protocol — VCI · VP · SD-JWT VCneeds an Es256Signer ↓attested_secure_keyshardware EC P-256 key + attestationprovides a HwKey ↓AttestedKeysSigner · 72 linesimplements Es256Signer using the hardware keypublicJwk() · signEs256() · attest()the three calls sdjwt_oid4vc makes — answered from hardwareSECURE HARDWARE — the private key never crosses this lineStrongBox / TEE · Secure Enclave
The wallet app depends on both libraries. sdjwt_oid4vc drives the holder protocol but needs an Es256Signer; attested_secure_keys mints a non-exportable hardware key. AttestedKeysSigner (72 lines) implements Es256Signer by delegating to the hardware key — the single seam where the two packages meet. Only handles, signatures and attestations cross back up; the private key stays in secure hardware.
// sdjwt_oid4vc never imports a key backend. It asks an
// Es256Signer for three things; we answer all three from
// the hardware key minted by attested_secure_keys.
class AttestedKeysSigner implements Es256Signer {
  AttestedKeysSigner(this._keys, this._key);
  final AttestedSecureKeys _keys;
  final HwKey _key;

  @override
  Future<Map<String, dynamic>> publicJwk() async =>
      _key.publicJwk.toJson().cast<String, dynamic>();

  @override
  Future<String> signEs256(String input) async {
    final sig = await _keys.sign(
      alias: _key.alias, payload: utf8.encode(input));
    return sig.jose; // raw R‖S, base64url — JOSE ES256
  }

  @override
  Future<KeyAttestation?> attest(String nonce) async {
    // Android X.509 chain / iOS App Attest — or null on an
    // emulator, where the PoP still binds the key via cnf.
    final att = await _keys.attest(
      alias: _key.alias, serverNonce: utf8.encode(nonce));
    return _normalize(att);
  }
}

The app itself

Three screens over a shared controller — no routing framework, just a bottom-nav index. Home generates the hardware key and shows a live acceptance checklist; Import redeems a credential offer (paste or scan a QR) then inspects, trusts and status-checks it; Present loads a verifier’s request, authenticates it, and discloses only the requested claims. The checklist below is the app’s own — every item flips green as the journey completes:

§1 success criteria · Home screen
  • Generate hardware key + read its JWK
  • Wrap the key as an Es256Signer
  • Issue — redeem offer (OID4VCI + tx_code + proof-of-possession)
  • Inspect — decode + display the credential's claims
  • Trust — verify issuer signature + validity window
  • Status — resolve the Token Status List
  • Present — OpenID4VP + hardware-signed Key-Binding JWT

The presentation step is where selective disclosure pays off: asked for one claim, the wallet reveals exactly that and withholds the rest — in the offline demo it discloses employment_status and keeps given_name and family_name on the device. The mechanics live in the sdjwt_oid4vc case study.

One holder core, two backends

Because the holder clients take an injected HTTP client, the same wallet runs against two completely different backends by swapping one object. Mode A is an in-process mock issuer + verifier — a fully offline self-test. Mode B points at the live EUDI reference wallet stack: it redeems a real PID offer from issuer.eudiw.dev, validates the credential’s X.509 chain to the bundled EU PID Issuer CA root, and presents back with the verifier’s encrypted direct_post.jwt response mode.

One holder core, two swappable backendsHolder core (sdjwt_oid4vc)Oid4vciClient · Oid4vpClient · StatusListResolvertakes an injected Oid4vcHttpMode A · mockin-process MockIssuerVerifieroffline — no networkissues a demo credential;discloses employment_status onlyMode B · live EUDIissuer.eudiw.dev over real HTTPreal PID · x5c → EU PID Issuer CAencrypted direct_post.jwtresponse to the reference verifierswap one injected object — the wallet code is identicalSame hardware key · same holder flow · same UIMode A proves the logic offline; Mode B proves interop with the real EU stack
The holder clients (Oid4vciClient, Oid4vpClient, StatusListResolver) take an injected Oid4vcHttp. Mode A wires them to an in-process mock issuer + verifier for a fully offline self-test. Mode B wires the same clients to the live EUDI reference issuer/verifier over real HTTP, validating the credential's X.509 chain to the bundled EU PID Issuer CA root and using the encrypted direct_post.jwt response mode. Only the injected transport changes.

Proven end to end

The demo runs, but the real assurance is an on-device integration test that drives the actual attested_secure_keys plugin — generateKey, sign and attest over the method channel, not a mock — through the entire flow and asserts every checklist item passes, including that the presented token contains only the requested claim.

And it interoperates with the real thing: the same app, in Mode B, has issued and presented a genuine PID credential against the EU reference issuer and verifier — the interop that hardened direct_post.jwt and nested-claim DCQL in the underlying library.

Design decisions & honest trade-offs

A reference build, not a product

No credential storage, no account system, no production key lifecycle. It exists to prove the two libraries compose into a working wallet — and it says so, rather than dressing a demo up as a shipping app.

The integration is the point

The interesting code isn’t the screens — it’s the 72-line adaptor and the one-line HTTP swap. Everything else is deliberately plain Material so the seams between the libraries are what you notice.

Runs on an emulator, honestly

Key generation asks for a TEE-backed, biometric-gated key and falls back if the hardware can’t provide one — the UI reports the assurance it actually got. Attestation is simply omitted where it isn’t available; the proof-of-possession still binds the key.

Trust anchors are bundled, on purpose

Mode B validates to the EU reference PID Issuer CA root, checked into the repo as dev trust material. Real trust-list management, revocation and RP policy stay out — those are an integrator’s job, the same line both libraries draw.

Status

Public source, device-verified in both modes. It completes the trilogy: the key layer, the protocol layer, and here the wallet that runs on both. As a demo it’s intentionally unpolished — the value is that the whole EUDI holder journey works, end to end, on real hardware and against the real EU stack.

Stack

FlutterDartattested_secure_keyssdjwt_oid4vcSD-JWT VCOpenID4VCIOpenID4VPEC P-256 · ES256Key-Binding JWTToken Status ListX.509 chain validationmobile_scanner (QR)integration_test