๐ชช A digital identity wallet, end to end, in Elixir
Mix.install([
{:attesto, "~> 2.3"},
{:attesto_client, "~> 2.7"},
{:kino, "~> 0.14"},
{:eqrcode, "~> 0.2"}
])
What we're building
Three parties, one credential โ the shape of the EU Digital Identity Wallet:
sequenceDiagram
participant I as ๐๏ธ Issuer
participant W as ๐ชช Wallet
participant V as ๐ Verifier
I->>W: Issue a Person ID credential (SD-JWT VC), bound to the wallet's key
V->>W: "Prove only what I ask" โ fresh nonce + DCQL query
W->>V: Present just those claims + a holder signature over the nonce
V->>V: Check issuer signature ยท holder binding ยท nonce ยท disclosed claims
The issuer signs a credential once, bound to the wallet's key. The verifier asks for specific claims. The wallet reveals only those โ everything else never leaves the device. That's selective disclosure.
Everything below runs locally on attesto
(issuer/verifier core) and attesto_client
(the wallet) โ no HTTP servers or mock transports. The encrypted example
requires Attesto 2.3 and AttestoClient 2.7. See the
Credential wallet guide to connect this local exercise
to real issuance, authorization, trust and consent flows.
Keys
# The issuer's signing key. Its public half is the trust anchor a verifier pins.
issuer_jwk = JOSE.JWK.generate_key({:ec, "P-256"})
issuer_pem = issuer_jwk |> JOSE.JWK.to_pem() |> elem(1)
{_kty, issuer_public} = JOSE.JWK.to_public_map(issuer_jwk)
# The wallet's holder key โ never leaves the wallet. The credential is bound to it,
# and the wallet signs each presentation with it.
holder_jwk = JOSE.JWK.generate_key({:ec, "P-256"})
{_kty, holder_public} = JOSE.JWK.to_public_map(holder_jwk)
:keys_ready
1. ๐๏ธ The issuer mints a credential
A Person Identification Data (PID) credential, bound to the wallet's key:
credential =
Attesto.SdJwtVc.issue(
[iss: "https://issuer.example.gov", vct: "urn:eudi:pid:1", pem: issuer_pem],
claims: %{
"given_name" => "Ada",
"family_name" => "Lovelace",
"birthdate" => "1815-12-10",
"nationality" => "GB",
"age_over_18" => true,
"age_over_21" => true
},
cnf: %{"jwk" => holder_public}
)
Kino.Text.new(credential)
That string is a SD-JWT VC: an issuer-signed JWT, then one ~-separated
disclosure per claim. Each disclosure can be revealed or withheld
independently โ the signature covers all of them either way.
2. ๐ชช The wallet receives and holds it
On receipt, the wallet verifies the issuer signature before trusting anything:
{:ok, received} = Attesto.SdJwtVc.verify(credential, issuer_public)
held = %{
format: "dc+sd-jwt",
credential: credential,
claims: received.claims,
holder_binding: %{"jwk" => holder_public}
}
received.claims
|> Map.drop(["cnf", "iss"])
|> Enum.map(fn {k, v} -> %{"claim" => k, "value" => inspect(v)} end)
|> Kino.DataTable.new(name: "In the wallet")
In a real flow the wallet would pick this up from an
openid-credential-offer:// link โ the kind you'd scan to onboard a credential.
Here's that QR:
offer =
%{
"credential_issuer" => "https://issuer.example.gov",
"credential_configuration_ids" => ["eu.europa.ec.eudi.pid.1"],
"grants" => %{
"urn:ietf:params:oauth:grant-type:pre-authorized_code" => %{
"pre-authorized_code" => "demo-code-123"
}
}
}
|> JSON.encode!()
("openid-credential-offer://?credential_offer=" <> URI.encode(offer))
|> EQRCode.encode()
|> EQRCode.svg(width: 240)
|> Kino.HTML.new()
3. ๐ The verifier asks โ you choose what it may see
Tick the claims the verifier is allowed to learn, then hit Present. Watch what reaches the verifier โ and what stays locked in the wallet.
Try presenting only age_over_21: a bar confirms you're old enough and learns
neither your name nor your birthdate.
request = %AttestoClient.Wallet.PresentationRequest{
client_id: "https://bar.example",
nonce: 16 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false),
response_uri: "https://bar.example/present",
response_mode: "direct_post",
client_metadata: %{
"vp_formats_supported" => %{
"dc+sd-jwt" => %{"sd-jwt_alg_values" => ["ES256"], "kb-jwt_alg_values" => ["ES256"]}
}
},
dcql_query: %{"credentials" => []}
}
pid_fields = ~w(given_name family_name birthdate nationality age_over_18 age_over_21)
form =
Kino.Control.form(
for(f <- pid_fields, do: {String.to_atom(f), Kino.Input.checkbox(f, default: f == "age_over_21")}),
submit: "๐ชช Present to verifier"
)
frame = Kino.Frame.new()
Kino.listen(form, fn %{data: data} ->
requested = for {field, true} <- data, do: Atom.to_string(field)
out =
if requested == [] do
Kino.Markdown.new("_Tick at least one claim for the verifier to request._")
else
# The verifier's request names only the claims it needs (DCQL).
req = %{
request
| dcql_query: %{
"credentials" => [
%{
"id" => "pid",
"format" => "dc+sd-jwt",
"meta" => %{"vct_values" => ["urn:eudi:pid:1"]},
"claims" => Enum.map(requested, &%{"path" => [&1]})
}
]
}
}
# The wallet builds a presentation: only the requested disclosures, plus a
# Key Binding JWT signed over the verifier's nonce (so it can't be replayed).
{:ok, vp_token} =
AttestoClient.Wallet.Presentation.build_vp_token(%{"pid" => held}, req,
holder_keys: %{"pid" => holder_jwk}
)
# The verifier verifies issuer signature, holder binding, and nonce.
{:ok, results} =
Attesto.VpToken.verify(vp_token,
nonce: req.nonce,
audience: req.client_id,
expected_query_ids: ["pid"],
issuer_jwks: issuer_public
)
# Take just the PID data fields (ignore JWT metadata like iat/iss/vct).
[verified_pid] = results["pid"]
seen = Map.take(verified_pid.claims, pid_fields)
hidden = pid_fields -- Map.keys(seen)
seen_rows =
seen
|> Enum.map(fn {k, v} -> "| `#{k}` | #{inspect(v)} |" end)
|> Enum.join("\n")
Kino.Markdown.new("""
### โ
Presentation accepted
Issuer signature valid ยท bound to the holder's key ยท nonce matched.
**What the verifier learned**
| claim | value |
|---|---|
#{seen_rows}
**๐ Never left the wallet:** #{if hidden == [], do: "_(nothing โ all disclosed)_", else: Enum.map_join(hidden, ", ", &"`#{&1}`")}
""")
end
Kino.Frame.render(frame, out)
end)
Kino.Layout.grid([form, frame], boxed: true, gap: 16)
4. ๐ Encrypt the presentation for its recipient
Selective disclosure limits what the verifier receives. Encryption protects
the response on its way there. In direct_post.jwt, both the credential and
the correlation state go inside the encrypted response.
The following example plays both roles locally. In a real wallet, the verifier's public encryption key comes from the authenticated request metadata; the wallet never receives the verifier's private key. Always verify that request and obtain consent before building the response.
recipient_key = JOSE.JWK.generate_key({:ec, "P-256"})
{_type, recipient_public} = JOSE.JWK.to_public_map(recipient_key)
recipient_public = Map.merge(recipient_public, %{"alg" => "ECDH-ES", "kid" => "demo-recipient"})
encrypted_request = %{
request
| response_mode: "direct_post.jwt",
state: "demo-correlation-state",
client_metadata: %{
"vp_formats_supported" => request.client_metadata["vp_formats_supported"],
"jwks" => %{"keys" => [recipient_public]},
"encrypted_response_enc_values_supported" => ["A256GCM", "A128GCM"]
},
dcql_query: %{
"credentials" => [
%{
"id" => "pid",
"format" => "dc+sd-jwt",
"meta" => %{"vct_values" => ["urn:eudi:pid:1"]},
"claims" => [%{"path" => ["age_over_21"]}]
}
]
}
}
{:ok, encrypted_vp_token} =
AttestoClient.Wallet.Presentation.build_vp_token(%{"pid" => held}, encrypted_request,
holder_keys: %{"pid" => holder_jwk}
)
{:ok, encrypted_form} =
AttestoClient.Wallet.Presentation.build_response(encrypted_request, encrypted_vp_token)
# This is the only form field that would be posted. It contains a compact JWE.
Map.keys(encrypted_form)
Now act as the verifier: decrypt with its private key, check the returned state, and verify the presentation's issuer signature, holder binding and nonce.
{:ok, decrypted_bytes, protected_header} =
Attesto.JWE.decrypt(recipient_key, encrypted_form["response"])
decrypted_response = JSON.decode!(decrypted_bytes)
true = decrypted_response["state"] == encrypted_request.state
# Final OID4VP responses contain an array of presentations in both modes.
[received_presentation] = decrypted_response["vp_token"]["pid"]
{:ok, verified_response} =
Attesto.VpToken.verify(%{"pid" => [received_presentation]},
nonce: encrypted_request.nonce,
audience: encrypted_request.client_id,
expected_query_ids: ["pid"],
issuer_jwks: issuer_public
)
Kino.Layout.grid([
Kino.Markdown.new("**Credential format:** `dc+sd-jwt` ยท **Encryption:** `#{protected_header["enc"]}`"),
Kino.DataTable.new(
for({claim, value} <- Map.take(hd(verified_response["pid"]).claims, pid_fields),
do: %{"disclosed claim" => claim, "value" => inspect(value)}),
name: "Decrypted and verified contents"
)
])
The holder signature and nonce checks are still necessary after decryption. Encryption authenticates the ciphertext to its recipient; it does not replace credential issuer trust or the user's disclosure decision.
What just happened
- The issuer signed the credential once, binding it to the wallet's key.
- The verifier sent a fresh nonce and a DCQL query naming only the claims it needed.
- The wallet returned a presentation with only those disclosures, plus a Key Binding JWT proving possession of the bound key โ signed over the nonce, so a captured presentation can't be reused elsewhere.
- The verifier checked the issuer signature, the holder binding, and the nonce, and saw nothing beyond what it asked for.
- The encrypted example delivered the presentation and state as one JWE and verified the disclosed contents after decryption.
That privacy model โ prove a fact without handing over the document โ is the whole point of the EU Digital Identity Wallet, and here it's a few dozen lines of Elixir.
The same libraries also do ISO mdoc (mobile driving licence), DPoP
sender-constrained issuance, batch issuance, key/wallet attestation, and the
encrypted credential exchanges and bounded deferred polling. The full OID4VCI
issuer + OID4VP verifier HTTP surface is available via
attesto_phoenix.
Continue with the Credential wallet guide for real offers, OAuth/PAR callbacks, HAIP certificates and attestation providers. The existing issuer/verifier and OIDC RP certifications cover their listed roles; wallet certification requires its own conformance run and submission.