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.
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.
// 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:
- 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.
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
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 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.
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.
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.