Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ewQwe Identity — Introduction

ewQwe (pronounced */juːˈkwiː/ — you-kwee) Identity is an open-source credential verifier developed in the EU, compatible with the EUDI/eIDAS wallet ecosystem.

The project provides a complete solution — including a multi-lingual admin UI — for verifying digital credentials such as Proof of Age, Person Identification Data (PID), and Mobile Driving Licences (mDL).

Out of the box: age over 18 verification using the widely deployed France Identité Numérique wallet.

France Identité Wallet

What it does

The Credential Verifier is a Rust server that accepts Verifiable Presentations from EUDI Wallets via OpenID4VP, validates their cryptographic integrity, and returns signed JWT attestations to Relying Parties.

It ships with an embedded multi-lingual admin UI — build the SPA, enable it in config, and it’s served at the server root URL with no separate frontend deployment.

A MIT-licensed demo webapp is also provided, showing how to embed credential verification into an existing web application.

Supported Standards

StandardPurpose
OpenID4VP 1.0Credential presentation protocol
ISO/IEC 18013-5mDoc / mDL credential format
ISO/IEC 18013-7 Annex BOnline mDoc presentation via OpenID4VP
SD-JWT VCSelective Disclosure JWT credentials
HAIPHigh Assurance Interoperability Profile (JAR, JWE, X.509)
EU Age Verification ProfilePrivacy-preserving age checks (DSA-compliant)

Components

ComponentDescriptionLicense
Credential VerifierCore server: VP Token validation, signed JWT attestations, OpenID4VP transaction lifecycleAGPL-3.0
Admin UIEmbedded SPA: QR-code-driven verification dashboard, user management, audit journal, i18nAGPL-3.0
Demo WebappRelying Party demo — vanilla TypeScript SPA that requests credentials via OpenID4VPMIT
Shared JS LibraryTypeScript library: DCQL query builders, protocol profiles, API clientMIT
OpenID4VP LibraryReusable Rust crate: DCQL, JAR signing, JWE decryption, transaction storesAGPL-3.0
Digital Credential LibraryRust crate: SD-JWT VC and mDoc credential building, signing, verificationAGPL-3.0

Roadmap

Based on your role:

RoleStart here
Deploying the verifierCredential Verifier Server
Using the admin dashboardCredential Verifier UI
Building a Relying PartyDemo Webapp then Demo Architecture
Understanding the standardsProtocols & Formats Summary

License

The open-core crates are licensed under AGPL-3.0. The TypeScript packages (@ewqwe/digital-identity and demo-webapp) are licensed under MIT.

Enterprise Version

The enterprise version adds production-grade infrastructure:

FeatureEnterprise Crate
OpenTelemetry (OTLP) tracing & metricsewqwe-enterprise-logging
PostgreSQL & Redis storesewqwe-enterprise-stores
APISIX/eIDAS authenticationewqwe-enterprise-auth
mTLS, multi-tenancy, K8s Helm chartsewqwe-enterprise-server

Credential Verification as a Service is coming soon — check ewqwe.eu for updates.

Credential Verifier Server

The Credential Verifier is a Rust (actix-web) server that validates Verifiable Presentations from EUDI Wallets via OpenID4VP, cryptographically verifies them, and returns signed JWT attestations to Relying Parties.

It handles the full OpenID4VP transaction lifecycle — from generating authorization requests (with optional JAR signing for HAIP) to receiving VP Tokens, decrypting JWE, validating signatures, and issuing attestations.

Architecture

flowchart TD
    RP[Relying Party]
    Wallet[EUDI Wallet]
    Verifier[Credential Verifier
    Rust - actix-web]
    Store[Transaction Store
    SQLite - Redis]
    Journal[Hash-chained Journal]
    CA[Trusted Issuer CAs]

    RP -->|POST /ewqwe_api/verify| Verifier
    RP -->|POST /ewqwe_api/openid4vp/init| Verifier
    Wallet -->|POST /ewqwe_api/openid4vp/direct_post| Verifier
    Wallet -->|GET /ewqwe_api/openid4vp/request/<id>| Verifier

    Verifier --> Store
    Verifier --> Journal
    Verifier --> CA

What it verifies

FormatChecks performed
mso_mdoc (COSE_Sign1)IssuerAuth MSO signature via x5chain certificate chain. MSO digest against each disclosed IssuerSignedItem. DeviceSignature (holder binding) via SessionTranscript / OpenID4VP handover. MSO validUntil / validFrom expiry.
dc+sd-jwtIssuer JWT signature via x5c certificate chain. _sd digest disclosure verification. KB-JWT holder binding (cnf.jwk, nonce, audience). exp claim expiry.

The verification process:

  1. Parse VP Token (mDoc CBOR or SD-JWT compact serialization)
  2. Extract selectively-disclosed claims
  3. Check expiry against MSO validity period or JWT exp
  4. Verify nonce binding — the state parameter looks up the stored transaction nonce, validated against the KB-JWT or SessionTranscript
  5. Validate signatures against the issuer’s certificate chain via the trusted CA directory
  6. Sign attestation — returns an ES256-signed JWT bound to the transaction_id

API Endpoints

OpenID4VP Transaction Flow

MethodPathDescription
POST/ewqwe_api/openid4vp/initCreate a new transaction. Accepts credential_type, profile, dcql_query. Returns transaction_id, request_uri, qr_code_data_url.
GET/ewqwe_api/openid4vp/request/{id}Serve the authorization request (signed JAR for HAIP, plain JSON for Annex A).
POST/ewqwe_api/openid4vp/direct_postReceive VP Token from wallet (the response_uri).
GET/ewqwe_api/openid4vp/status/{id}Poll transaction status (pending, scanned, verified, failed, expired).

Verification

MethodPathDescription
POST/ewqwe_api/verifyVerify a VP Token. Accepts vp_token, state, client_id. Returns success, claims, attestation (signed JWT).
POST/ewqwe_api/dc_api/verifyVerify a DC API credential. Accepts device_response_b64 (pre-decrypted mDoc), nonce, doc_type.
GET/ewqwe_api/dc_api/nonceGenerate a DC API nonce for replay protection.

Utility

MethodPathDescription
GET/versionServer version.
GET/ewqwe_api/openid4vp/.well-known/jwks.jsonPublic JWK set (for JAR verification and JWE encryption).

Journal

MethodPathDescription
GET/ewqwe_api/journal/{username}/entriesList verification entries (paginated, filterable by date).
GET/ewqwe_api/journal/{username}/verifyVerify hash-chain integrity.
GET/ewqwe_api/journal/{username}/downloadDownload journal as JSON.

Installation

Prerequisites

  • Rust 1.70+
  • pnpm >= 9 (for building the admin UI SPA)

The open-core uses SQLite and in-memory stores by default — no PostgreSQL or Redis needed for basic operation.

Quick Start (Docker)

docker pull ghcr.io/rd-ewqwe/ewqwe-identity:latest

docker run --rm -p 9888:9888 \
  -e PUBLIC_ROOT_URL="https://verifier.your-domain.com" \
  --name credential-verifier \
  ghcr.io/rd-ewqwe/ewqwe-identity:latest

Navigate to http://localhost:9888 and complete the one-time bootstrap to create an admin account.

See the Container README for persistent storage, TLS, Kubernetes, and production signer certificates.

Build from Source

# Build the server
cargo build --release -p ewqwe_credential_verifier_server

# Build the admin UI SPA (optional — see Credential Verifier UI page)
cd crates/ewqwe-credential-verifier-ui/ui
pnpm install && pnpm build

Start the server:

cargo run -p ewqwe_credential_verifier_server -- \
  crates/ewqwe-credential-verifier-server/credential-server.toml

As a Rust Dependency

[dependencies]
ewqwe_credential_verifier_client = { git = "https://github.com/rd-ewqwe/ewqwe-identity" }

Configuration

The server uses a TOML config file. It searches for credential-server.toml in the current directory, then at the platform-specific application support path.

Minimal Config (Development)

host_name = "0.0.0.0"
host_port = 9888
public_root_url = "http://localhost:9888"

[verifier_ui]
enabled = true
ui_dist_path = "../ewqwe-credential-verifier-ui/ui/dist"

Full Configuration Reference

host_name = "0.0.0.0"
host_port = 9443
public_root_url = "https://verifier.example.com:9443"
rust_log = "info,credential_verifier=debug"
# Authentication disabled for local dev:
disable_authentication = true
disabled_authentication_user = "local_tests_user"

# TLS (mutual TLS supported)
[tls_params]
server_private_key = "certs/server.key.pem"
server_certificate = "certs/server.cert.pem"
server_ca_chain = "certs/ca.chain.pem"
client_ca_cert_chain = "certs/client-ca.pem"     # mTLS clients

# OpenID4VP transaction handling
[openid4vp_config]
transaction_ttl_secs = 300                         # Default: 5 minutes

[openid4vp_config.transaction_store]
backend = "sqlite_file"                            # sqlite_memory | sqlite_file | redis | postgres
path = "transactions.db"

# HAIP: JAR signing and JWE decryption
[openid4vp_config.haip_config]
x509_cert_path = "certs/server.fullchain.pem"
x509_key_path = "certs/server.key.pem"

# Hash-chained audit journal
[journal_config]
enabled = true
backend = "sqlite_file"
path = "journal.db"

# Admin UI (see Credential Verifier UI page)
[verifier_ui]
enabled = true
app_name = "My Verifier"
session_secret = "..."                              # Generate: openssl rand -hex 64
ui_dist_path = "../ewqwe-credential-verifier-ui/ui/dist"

# OpenTelemetry (OTLP) — enterprise feature
[tracing_config]
service_name = "credential_verifier"

[tracing_config.otlp]
enabled = false
url = "http://localhost:4317"

Transaction Store Backends

BackendUse caseAvailability
sqlite_memoryDevelopment / testing (default)Open-core
sqlite_fileSingle-instance with persistenceOpen-core
redisMulti-instance / HA with TTL-native expiryEnterprise
postgresEnterprise deploymentsEnterprise

Verification Journal

The journal is an append-only, hash-chained audit log of every verification event. Each entry’s hash is chained to the previous entry, forming a tamper-evident chain.

Formula: entry_hash = SHA-256(previous_hash || attestation_signature_hash || created_at_iso8601)

The /ewqwe_api/journal/{username}/verify endpoint recomputes all hashes to detect tampering.

Security

Nonce Replay Prevention

The nonce is stored server-side in the transaction store. The verify endpoint receives the state field, loads the transaction, and compares the stored nonce against the holder-binding proof (KB-JWT nonce for SD-JWT VC, SessionTranscript nonce for mDoc). The nonce is never accepted from the HTTP request body.

Trusted Issuer CA Directory

All *.pem files in the configured issuers CA directory are loaded as trusted anchors at startup. Certs are cached — restart the server to pick up changes.

Enterprise Version

PostgreSQL and Redis stores, OpenTelemetry (OTLP) tracing, APISIX/eIDAS authentication, mTLS, multi-tenancy, and K8s Helm charts are available in the enterprise version. Contact ewqwe.eu for details.

Credential Verifier UI

The Credential Verifier UI is a Vite+TypeScript+Tailwind SPA served directly by the credential verifier. It provides a self-contained UI for credential verification operators — no separate deployment is required.

The SPA is built from crates/ewqwe-credential-verifier-ui/ui/ and served from the root URL (/). All API endpoints are under /api/v1/.

UI Overview


Building the UI

The SPA must be built before the credential verifier can serve it.

cd crates/ewqwe-credential-verifier-ui/ui
pnpm install
pnpm build          # outputs to dist/

The credential-server.toml [verifier_ui] section tells the server where to find the built assets:

[verifier_ui]
ui_dist_path = "../ewqwe-credential-verifier-ui/ui/dist"

Paths are resolved relative to the directory containing the config file. The server logs a warning and skips serving the SPA if the directory is not found at startup.

Development Mode (Vite Dev Server)

Run the Vite dev server alongside the credential verifier for hot-module reload during UI development:

# Terminal 1 — credential verifier (API + OpenID4VP)
cd credential_verifier && cargo run -- --config credential-server.toml

# Terminal 2 — Vite dev server (proxies API to the verifier)
cd crates/ewqwe-credential-verifier-ui/ui && pnpm dev

Open http://localhost:5175 in your browser. The Vite proxy forwards /api/v1/*, /ewqwe_api/*, /.well-known/*, and /version to the credential verifier over HTTPS (secure: false trusts the self-signed dev certificate).


Enabling the Credential Verifier UI

Add the following section to credential-server.toml:

[verifier_ui]
enabled = true
app_name = "ACME.eu"                          # optional (default: "Credential Verifier UI")
# logo_url = "https://example.com/logo.png"   # optional logo
# session_secret = "<128 hex chars>"           # recommended for production
ui_dist_path = "../ewqwe-credential-verifier-ui/ui/dist"

When enabled = false (the default if the section is absent), all /api/v1/* routes return 404 and the SPA is not served.

Session Secret

Session cookies are signed and encrypted with a key derived from session_secret. When the key is not set, the server generates a random key at startup — all active sessions are invalidated on restart.

For production, generate a stable key:

openssl rand -hex 64

Paste the output as the value of session_secret.

Public URL

When the server is behind a reverse proxy or NAT, the auto-detected URL from the incoming request may not match the externally reachable address. Set public_url to the canonical HTTPS URL that wallets will use for the OpenID4VP redirect:

[verifier_ui]
public_url = "https://demo.ewqwe.local:9443"

When omitted, the URL is derived from the Host header of the incoming request.

Allowed Credential Types

Restrict which credential types can be requested from this server instance:

[verifier_ui]
allowed_credential_types = ["proof-of-age", "mdl"]

Valid values: "proof-of-age", "mdl", "national-id". An empty list (or omitting the key) allows all types.

This setting controls what appears in the dashboard’s credential type dropdown. Per-user restrictions can further narrow the selection (see Per-User Credential Permissions).


Database Backend

The Credential Verifier UI manages user accounts in a separate database from the main credential store. Four backends are supported:

BackendUse-caseConfig
sqlite_memory (default)Development / testing(no extra config needed)
sqlite_fileSingle-instance with persistencepath = "/var/lib/ewqwe/verifier_ui.db"
postgresMulti-instance / HAurl = "postgres://user:pw@host/db"
mysqlMySQL / MariaDB environmentsurl = "mysql://user:pw@host/db"
[verifier_ui.db]
backend = "sqlite_file"
path    = "/var/lib/ewqwe/verifier_ui.db"
[verifier_ui.db]
backend = "postgres"
url     = "postgres://ewqwe:secret@db.internal/ewqwe_verifierapp"
[verifier_ui.db]
backend = "mysql"
url     = "mysql://ewqwe:secret@db.internal/ewqwe_verifierapp"

Session Store Tradeoffs

The actix-session middleware manages HTTP sessions (authentication cookies). The credential verifier currently uses cookie-based session storage.

ApproachProsCons
Cookie (current)Zero infrastructure — all session data travels in the encrypted cookie. No server-side store needed.4 KB size limit per cookie; no server-side revocation (session lives until expiry); every request carries full state.
RedisTTL-native expiry; instant server-side revocation; suitable for distributed / multi-instance deployments.Requires a running Redis instance; additional network hop per request.
SQL (SQLite/Postgres)Reuses the same database already available; unlimited session size; server-side revocation.No native TTL — requires a periodic cleanup job or trigger; extra write per request.

For most single-instance deployments the cookie store is sufficient. For multi-instance or high-availability setups, consider adding Redis as a session backend (requires implementing the actix-session SessionStore trait for the chosen store).


First-Time Setup

On the first visit, navigate to https://<server>/.

Because no users exist yet, the app shows the bootstrap form. Fill in the admin email and a password of at least 12 characters.

This POST /api/v1/setup/bootstrap endpoint is automatically locked after the first admin is created — subsequent calls return 409 Conflict.


Roles

RolePermissions
adminFull access: user management, journal, QR generation
verifierQR generation and status polling only

The first account created via bootstrap is always an admin with is_superadmin = true and cannot be deleted through the UI.


Generating a QR Code

  1. Sign in at https://<server>/.
  2. On the Home page, select the Credential Type from the dropdown (Proof of Age, mDL, or National ID).
  3. Click Generate QR Code.
  4. A QR code is displayed for the selected credential type. The holder scans it with their digital wallet; the status badge updates automatically every 2 seconds:
StatusMeaning
pendingWaiting for the wallet to scan
scannedWallet received the request
verifiedCredential verified — presentation accepted
failedPresentation was rejected
expiredTransaction timed out (default TTL: 300 s)
  1. Once verified, click New Verification to start another session, or Cancel to return to the credential type selection.

Single-Credential Verifiers

When a user has only one allowed credential type (or the server is configured with a single type), the credential type dropdown and cancel button are hidden and the QR code is generated automatically on page load.

The credential type determines the OpenID4VP profile used:

Credential TypeProfileNamespace
proof-of-ageAnnex A (EU AV)eu.europa.ec.av.1
mdlHAIPorg.iso.18013.5.1.mDL
national-idHAIPeu.europa.ec.eudi.pid.1

Admin: User Management

Navigate to Users (admin only) to:

  • View all accounts, their role and active status.
  • Add User — creates a new verifier or admin account.
  • Edit — update name, role, active status, password, and allowed credential types.
  • Delete — removes the account (superadmin is protected).

Password requirements: minimum 12 characters.

Per-User Credential Permissions

Each user can be restricted to specific credential types via the Allowed Credential Types checkboxes in the user modal. When all checkboxes are unchecked, the user inherits the server-level allowed_credential_types setting. When one or more are checked, QR generation is limited to those types only.

This allows an admin to, for example, let one operator verify only proof-of-age while another can also verify mDL and national IDs.


Admin: Settings

Navigate to Settings (admin only) to configure:

  • Company Name — displayed in the navigation bar and login screen (default: “ACME.eu”).
  • Logo URL — HTTPS URL or a data:image/png;base64,… data URL. When blank, the built-in ewQwe logo is used.
  • Language — choose a specific UI language or use the browser default.
  • Credential Claims — configure which PID (National ID) and mDL (Driver’s License) claims to request. Claims configuration is stored in the browser’s localStorage.

Admin: Journal

Navigate to Journal (admin only) to browse the verification audit log. Each entry is attributed to the Credential Verifier UI user who initiated the transaction.

Available filters:

  • Verifier — show entries belonging to a specific operator.
  • From / To — date range filter.

Entries are paginated (50 per page). The journal backend must be enabled in the credential-server configuration (see [journal] section).


Internationalisation (i18n)

The UI fetches locale strings from:

GET /api/v1/i18n?lang=<code>

Supported language codes:

CodeLanguage
enEnglish (default)
deGerman
frFrench
itItalian
esSpanish
svSwedish
plPolish
csCzech
hrCroatian

Language Detection

By default the app detects the browser’s language (navigator.language) and uses the closest supported locale, falling back to English. Users can override this in the Settings page by choosing a specific language from the dropdown, or selecting Default (browser language) to restore automatic detection.

The language preference is stored in localStorage (verifier_ui_lang) and persists across sessions.

Locale files are embedded in the binary at compile time from
crates/ewqwe-verifier-app/src/static/i18n/ and served via GET /api/v1/i18n?lang=<code>.


API Reference

All API endpoints are under /api/v1/ and require Content-Type: application/json for POST/PUT requests. Sessions are managed via HttpOnly cookies.

Setup

MethodPathBodyDescription
POST/api/v1/setup/bootstrap{email, password, first_name, last_name?}One-time admin creation
GET/api/v1/setup/status{"bootstrapped": bool} — public endpoint

Auth

MethodPathBodyDescription
POST/api/v1/auth/login{email, password}Sign in, sets session cookie
POST/api/v1/auth/logoutInvalidate session
GET/api/v1/auth/meCurrent user profile

QR Code

MethodPathBodyDescription
POST/api/v1/qr/generate{credential_type?}Start a new OpenID4VP transaction. credential_type: "proof-of-age" (default), "mdl", or "national-id".
GET/api/v1/qr/{id}/statusPoll transaction status

generate response:

{
  "transaction_id": "...",
  "qr_code_data_url": "data:image/png;base64,...",
  "authorization_request_uri": "openid4vp://...",
  "expires_in": 300
}

Settings (public)

MethodPathBodyDescription
GET/api/v1/settings{"app_name": "...", "logo_url": "...|null", "allowed_credential_types": [...]}

Admin Settings

Use these endpoints to manage the display settings for the credential verifier UI.

MethodPathBodyDescription
PUT/api/v1/admin/settings{app_name?, logo_url?}Persist display settings

status response:

{
  "status": "pending | scanned | verified | failed | expired",
  "expires_in": 287
}

Admin Users

MethodPathBodyDescription
GET/api/v1/admin/usersList all users
POST/api/v1/admin/users{email, password, first_name, last_name?, role?, allowed_credential_types?}Create user
PUT/api/v1/admin/users/{id}{first_name?, last_name?, role?, is_active?, new_password?, allowed_credential_types?}Update user
DELETE/api/v1/admin/users/{id}Delete user

Admin Journal

MethodPathQueryDescription
GET/api/v1/admin/journaluser_id, limit (≤200), offsetList journal entries attributed to QR app users

i18n

MethodPathQueryDescription
GET/api/v1/i18nlangGet locale strings JSON

Demo Webapp (Relying Party)

The Demo Webapp is a vanilla TypeScript SPA that demonstrates how to integrate credential verification into your own web application. It’s MIT-licensed so you can freely adapt and embed it.

It implements the full OpenID4VP cross-device flow:

  1. Select a credential type (Proof of Age, mDL, or National ID)
  2. The webapp initiates a transaction with the Credential Verifier
  3. A QR code is displayed for the user to scan with their EUDI Wallet
  4. The wallet presents the credential to the verifier
  5. The webapp polls for the result and displays the verification outcome

Running

cd typescript
pnpm install
pnpm --filter @ewqwe/digital-identity build
pnpm --filter @ewqwe/demo-webapp dev    # https://localhost:5174

The demo webapp connects to the Credential Verifier at the URL configured in its environment. See the TypeScript workspace README for details.

Protocol Support

The webapp supports two OpenID4VP profiles, selected automatically based on credential type:

Credential TypeProfileClient ID SchemeResponse Mode
Proof of AgeEU Age Verification (Annex A)redirect_uridirect_post
mDL / National IDHAIPx509_san_dnsdirect_post.jwt

For details on these profiles, see the Protocols & Formats Summary.

Integration Pattern

The demo webapp demonstrates the standard integration pattern for a Relying Party:

// 1. Initiate a transaction
const init = await fetch("/api/openid4vp/init", {
  method: "POST",
  body: JSON.stringify({ credential_type: "proof-of-age" })
});
const { transaction_id, qr_code_data_url } = await init.json();

// 2. Display QR code for wallet scanning
displayQRCode(qr_code_data_url);

// 3. Poll for verification result
const status = await pollStatus(transaction_id);

// 4. Receive signed attestation
if (status.result === "verified") {
  const attestation = status.attestation; // Signed JWT
}

See the Demo Architecture page for the full system setup and testing flow.

Source Code

The demo webapp is in typescript/demo-webapp/. It uses:

  • @ewqwe/digital-identity — shared library for DCQL query building, protocol profiles, and the verifier API client
  • Vite — dev server and build tool
  • Vanilla TypeScript — no framework

Demo Architecture

The demo system showcases a complete credential verification flow:

  1. Demo Webapp — a Relying Party SPA that requests credentials
  2. Credential Verifier — the backend that cryptographically verifies presentations
  3. Wallet — a mobile wallet app (EUDI Wallet, France Identité, or Age Verification App)

System Architecture

flowchart LR
    subgraph Browser["Browser"]
        Webapp["Demo Webapp<br/>TypeScript SPA"]
    end

    subgraph Server["Server"]
        Verifier["Credential Verifier<br/>Rust / actix-web"]
    end

    subgraph Mobile["Mobile Device"]
        Wallet["EUDI Wallet"]
    end

    Webapp -->|Init transaction| Verifier
    Webapp -->|Display QR code| Wallet
    Wallet -->|Scan QR, present credential| Verifier
    Webapp -->|Poll status| Verifier
    Verifier -->|Signed attestation| Webapp

The credential verifier serves both the verifier API and (when the admin UI is enabled) the embedded SPA at the root URL.

Setting Up

1. Start the Credential Verifier

cargo run -p ewqwe_credential_verifier_server -- \
  crates/ewqwe-credential-verifier-server/credential-server.toml

The development config uses HTTP on port 9888 with authentication disabled.

2. Start the Demo Webapp

cd typescript
pnpm install
pnpm --filter @ewqwe/digital-identity build
pnpm --filter @ewqwe/demo-webapp dev

The webapp runs at https://localhost:5174 and proxies API calls to the verifier.

3. Run a Wallet

For full end-to-end testing, you need a wallet on a mobile device (physical or emulator):

For local network setup connecting the emulator to the verifier, see Local Network Setup.

Port Assignments

ComponentPortDescription
Credential Verifier9888 (dev)Verification server
Demo Webapp (Vite)5174RP web interface

Next Steps

User Journey

This page describes the end-to-end credential verification flow from the perspective of a Relying Party.

The Two Profiles

The EU Digital Identity ecosystem defines two main profiles:

ProfilePurposeTransportRequest SigningResponse
EU Age Verification (Annex A)Privacy-preserving age checksopenid4vp:// deep link or QR, direct_postNone (redirect_uri scheme)Plain VP Token
HAIPHigh-assurance identity (PID, mDL)openid4vp:// deep link or QR, direct_post.jwtJAR with x5c certificate chainJWE-encrypted VP Token

For a detailed protocol and format reference, see the Protocols & Formats Summary.

OpenID4VP Cross-Device Flow (QR Code)

This is the primary production flow. The user scans a QR code on the Relying Party’s screen with their mobile wallet.

sequenceDiagram
    participant User
    participant RP as Relying Party
    participant Verifier as Credential Verifier
    participant Wallet as EUDI Wallet

    User->>RP: Visits website, clicks "Verify Age"
    RP->>Verifier: POST /ewqwe_api/openid4vp/init
    Verifier-->>RP: { transaction_id, qr_code_data_url, request_uri }
    RP->>User: Displays QR code

    User->>Wallet: Scans QR code
    Wallet->>Verifier: GET /ewqwe_api/openid4vp/request/{id}
    Verifier-->>Wallet: Authorization request (JAR or plain JSON)
    Wallet->>User: Consent dialog — "Share proof of age?"
    User->>Wallet: Approves

    Wallet->>Verifier: POST /ewqwe_api/openid4vp/direct_post (VP Token)
    Verifier->>Verifier: Validate signatures, expiry, nonce
    Verifier-->>Wallet: 200 OK

    RP->>Verifier: GET /ewqwe_api/openid4vp/status/{id} (polling)
    Verifier-->>RP: { status: "verified", attestation: "<signed JWT>" }
    RP->>User: Displays verification result

Verification Flow (Common to All)

Regardless of the transport (QR code, deep link, or DC API), the verification steps are the same:

  1. RP initiates a transaction with the Credential Verifier, specifying the credential type and claims
  2. Wallet presents the VP Token to the verifier (encrypted with JWE for HAIP, plain for Annex A)
  3. Verifier validates: decrypts JWE if present, verifies issuer signatures against the trusted CA directory, checks disclosure digests, validates holder binding (KB-JWT or DeviceSignature), and checks expiry
  4. Verifier returns a signed JWT attestation to the RP, confirming the verified claims

The RP uses the attestation for access control and session establishment.

Installing the EUDI Android Wallet on Android Studio

What is the EUDI Wallet?

The EUDI Wallet (European Digital Identity Wallet) is the official reference implementation of the EU Digital Identity Wallet, developed by the European Commission. It allows EU citizens to securely store and present digital credentials such as:

  • EU Personal Identification Data (PID) - Digital identity credentials
  • Mobile Driving Licence (mDL) - ISO 18013-5 compliant digital driving licences
  • Age verification attestations - Proof of age without revealing exact birth date

The wallet implements key standards including OpenID4VP (for verifiable presentations), OpenID4VCI (for credential issuance), and ISO 18013-5 (for mDL). It serves as the reference implementation for testing interoperability with Relying Parties and Credential Verifiers.

The EUDI Android Wallet source code is open source and available on GitHub.

Important: EUDI Wallet vs. Age Verification App

There are two separate wallet applications with different purposes:

WalletProfileClient ID SchemesCredentialsURL Scheme
EUDI WalletHAIP (High Assurance)x509_san_dns, x509_hashPID, mDL, variouseudi-openid4vp://
Age Verification AppAnnex Aredirect_uriProof of Age onlyav://

The EUDI Wallet (this guide) does not support the redirect_uri client ID scheme mandated by the EU Age Verification Profile (Annex A). To test Annex A-compliant age verification, you need the Age Verification App instead.

For testing with the EUDI Wallet, your Relying Party must:

  1. Use the x509_san_dns or x509_hash client ID scheme
  2. Sign Authorization Requests as JWTs with an x5c certificate chain in the header
  3. Add your verifier’s root CA certificate to the wallet’s Reader Trust Store

See the User Journey for a detailed comparison of both profiles.

ewQwe Demo Setup

⚠️ FOR TESTING ONLY — The ewQwe fork uses debug-only CA pinning to trust the ewQwe self-signed test certificates. These modifications must not be used in production. Release builds use the standard EUDI trust store with no overrides.

This section describes how to run the ewQwe fork of the EUDI Wallet together with the ewQwe Relying Party Demo Webapp for end-to-end HAIP testing on a local Android emulator.

Repository: https://github.com/rd-ewqwe/eudi-app-android-wallet-ui/

ewQwe Demo Prerequisites

Step 1: Clone the Repository

git clone https://github.com/rd-ewqwe/android-eudi-haip-wallet.git
cd android-eudi-haip-wallet

Step 2: Create and Start the Emulator

Run the provided setup script from the project root. The script requires a rootable (Google APIs, non-Play Store) system image:

./start_ewqwe_eudi_emulator.sh

What this script does:

  1. Installs the correct Android system image if not already present
  2. Creates a custom AVD named EUDI_Dev_Device (Pixel 6 Pro profile)
  3. Enables hardware keyboard passthrough for typing on the emulator
  4. Starts the emulator with -writable-system (required for host mapping)
  5. Maps demo.ewqwe.local inside the emulator to your machine’s LAN IP address — this allows the wallet to reach your local dev servers

To map demo.ewqwe.local manually (if you already have a running emulator):

adb root && adb shell "echo '10.0.2.2  demo.ewqwe.local' >> /etc/hosts"

Physical device? The emulator’s 10.0.2.2 alias is emulator-only. For a physical Android device on your LAN, use Local Network DNS Setup instead.

Step 3: Build and Run the App

  1. Open the project in Android Studio
  2. Select the app module and the EUDI_Dev_Device emulator
  3. Click Run (▶️) to deploy and start the EUDI Wallet app

Step 4: Initialize Documents

Once the app is running on the emulator:

  1. Follow the on-screen prompts to create a PIN code
  2. Tap the “+” icon and select “Add a Document from List”
  3. Select both “mDL (MSO MDOC)” and “PID (MSO MDOC)” from the https://euidw.dev issuer
  4. When prompted for the country, select “Form EU”
  5. Fill in the test form, submit it, and authorize the issuance

Step 5: Open the Relying Party Demo Webapp

  1. Open Chrome on the Android emulator
  2. Navigate to https://demo.ewqwe.local:5174
  3. Proceed past the certificate warning (expected — the demo uses a self-signed certificate)
  4. The Demo Webapp should load

Step 6: Request HAIP Credentials

  1. From the webapp, select a HAIP credential type (mDL or National ID) and initiate a request
  2. This triggers a deep link that opens the EUDI Wallet
  3. The wallet validates the certificate chain against the ewQwe CA bundled in assets/ewqwe_dev_cas/ and proceeds
  4. Approve the credential sharing in the wallet
  5. The webapp displays the verified claims

Debug Certificate Trust (Technical Details)

The ewQwe fork uses debug-only CA pinning instead of trust-all bypasses. In DEBUG builds only:

MechanismFileWhat it does
CA-pinned TLSnetwork-logic/.../di/NetworkModule.ktLoads assets/ewqwe_dev_cas/*.pem into a KeyStore and builds an X509TrustManager from it. Standard hostname verification still applies. No-op in RELEASE builds.
CA-pinned Reader Trust Storecore-logic/.../di/LogicCoreModule.ktPasses the same CA certificates as trust anchors to ReaderTrustStore.getDefault(). JAR x5c chains are validated against these CAs. No-op in RELEASE builds; production trust anchors from WalletCoreConfigImpl.configureReaderTrustStore() apply instead.

Adding a new developer CA: drop a .pem or .crt file into resources-logic/src/main/assets/ewqwe_dev_cas/ and rebuild — no code change required.


Local Network DNS Setup

The x509_san_dns three-way binding rule means demo.ewqwe.local must be DNS-resolvable on every device used for testing. There are two approaches:

Option A — Android Emulator (easiest)

The emulator uses 10.0.2.2 as its alias for the host machine. The setup script injects the mapping automatically; to do it manually:

adb root && adb shell "echo '10.0.2.2  demo.ewqwe.local' >> /etc/hosts"

Option B — Physical Android Device via dnsmasq (LAN)

dnsmasq turns your dev Mac into a local DNS server that resolves demo.ewqwe.local to your machine’s LAN IP for any device on the same Wi-Fi network.

1. Install and configure dnsmasq:

brew install dnsmasq

# Replace <YOUR-LAN-IP> with your machine's LAN address (e.g. 192.168.1.42)
# Find it with: ipconfig getifaddr en0
echo "address=/demo.ewqwe.local/<YOUR-LAN-IP>" >> /opt/homebrew/etc/dnsmasq.conf

sudo brew services restart dnsmasq

2. Verify it works on your Mac:

dig @127.0.0.1 demo.ewqwe.local
# Should resolve to your LAN IP

3. Point the Android device at your Mac’s DNS:

On the Android device: Settings → Wi-Fi → long-press your network → Modify network → Advanced → IP settings: Static then set DNS 1 to <YOUR-LAN-IP>.

No root access required on the Android device. Any device on your Wi-Fi that uses this DNS setting will resolve demo.ewqwe.local correctly.

Custom Hostnames

If you regenerate the test certificates with a different DNS SAN (e.g. your machine’s mDNS name), update public_root_url in credential_verifier/credential-server.toml to match, and use the same hostname in your DNS setup.


Quick Start: Download APK Directly (No Build Required)

You don’t need to build the EUDI Wallet from source. The easiest way to install it is to download the pre-built APK directly from GitHub using Chrome on the Android emulator:

  1. Install Android Studio and create an emulator (see Setting Up an Android Emulator)
  2. Enable Developer Mode on the emulator (see Enabling Developer Mode) - this is required before installing external APKs
  3. Open Chrome on the emulator
  4. Navigate to EUDI Wallet Releases
  5. Download the latest APK (e.g., app-demo-debug.apk)
  6. Open the downloaded file and tap Install
  7. If prompted about “unknown sources”, allow Chrome to install apps

Note: Use the demo variant for testing with the EU demo infrastructure, or dev for development environments.


This guide also explains how to build the wallet from source if you need to modify the code or debug the application.

Table of Contents

HAIP Profile Requirements

The EUDI Wallet implements the High Assurance Interoperability Profile (HAIP), which is more restrictive than the Annex A profile used by the Age Verification App. Your Relying Party must meet these requirements to work with the EUDI Wallet:

1. Client ID Scheme: x509_san_dns or x509_hash

The EUDI Wallet only accepts these client identifier schemes:

SchemeFormatTrust Verification
x509_san_dnsx509_san_dns:<DNS>Verifier’s certificate must have a dNSName SAN matching the client ID (e.g. x509_san_dns:demo.ewqwe.local for the demo certs)
x509_hashx509_hash:sha-256:base64url_encoded_hashVerifier’s certificate must match the hash

The redirect_uri scheme from Annex A is not supported by the EUDI Wallet.

The Three-Way Binding Constraint (x509_san_dns)

When using x509_san_dns, the wallet unconditionally enforces three conditions that form a transitive equality chain:

PropertyMust equalEnforced by
client_id bare value (e.g. demo.ewqwe.local)A dNSName entry in the leaf certificate SAN of the JAR’s x5c headerRequestAuthenticator in eudi-lib-jvm-openid4vp-kt
response_uri hostnameThe client_id bare valueRequestObjectValidator in eudi-lib-jvm-openid4vp-kt
TLS server cert SANThe response_uri hostnameStandard TLS hostname verification

By transitivity: the hostname where the wallet posts the VP Token must be a DNS SAN on the JAR signing certificate.

response_uri vs request_uri — These are two distinct URLs that happen to share the same hostname in this project:

  • request_uri: the endpoint the wallet fetches the signed Authorization Request (JAR) from
  • response_uri: the endpoint the wallet posts the VP Token to (direct_post / direct_post.jwt)

The host-match check applies to response_uri, not request_uri.

Both checks live in the upstream Maven library eudi-lib-jvm-openid4vp-kt — they cannot be bypassed through wallet application code. The practical consequence for local network testing is that demo.ewqwe.local must be DNS-resolvable on every test device. See Local Network DNS Setup for options.

2. Signed Authorization Request (JAR)

All Authorization Requests must be signed JWTs. The JWT header must include:

  • alg: Signing algorithm (e.g., ES256 for P-256 ECDSA)
  • x5c: X.509 certificate chain as an array of base64-encoded certificates (leaf first)
  • kid: Key identifier (typically the certificate’s thumbprint)

Example JWT header:

{
  "alg": "ES256",
  "typ": "oauth-authz-req+jwt",
  "x5c": [
    "MIIBtjCCAVygAwIBAgIUEo...",  // Leaf certificate
    "MIIBxjCCAWygAwIBAgIUAb..."   // Intermediate CA
  ],
  "kid": "7SJZ5d9..."
}

3. Response Mode: direct_post.jwt

The EUDI Wallet uses response_mode=direct_post.jwt, meaning the VP Token is wrapped in a signed JWT before being POSTed to the response_uri. Your RP must be able to unwrap and verify this JWT.

4. Reader Trust Store

The root CA that signed your verifier’s certificate must be present in the wallet’s Reader Trust Store. The EUDI Wallet comes pre-configured with EU PID issuer CAs, but does not include public CAs like Let’s Encrypt by default.

If your verifier uses a Let’s Encrypt certificate or a custom CA, you must rebuild the wallet with your root CA.

Why These Requirements?

The HAIP profile is designed for high-assurance credentials like PID and mDL, where:

  • The verifier’s identity must be cryptographically proven (via certificate chain)
  • Trust is established through a pre-defined set of trusted CAs
  • Credential data requires stronger protection (JWT-wrapped responses)

For age verification scenarios where these high-assurance requirements are not necessary, use the Age Verification App with the simpler Annex A profile.

Prerequisites

Before you begin, ensure you have:

  • macOS (Sonoma or later recommended) or Windows 10/11 or Linux
  • At least 16 GB of RAM (recommended for running emulators)
  • At least 20 GB of free disk space for Android Studio, SDKs, and emulators
  • A stable internet connection for downloading SDKs and dependencies
  • JDK 21 (Android Studio will manage this, but external builds may require it)

Minimum Device Requirements for EUDI Wallet

The EUDI Android Wallet requires:

  • API level 29 (Android 10) or higher

Installing Android Studio

Android Studio is the official IDE for Android development, provided by Google. It includes everything you need to build, test, and debug Android applications. It runs on macOS, Windows, and Linux.

Step 1: Download Android Studio

  1. Visit the official Android Studio download page
  2. Click Download Android Studio
  3. Accept the terms and conditions
  4. Choose the appropriate version for your operating system:

macOS:

  • Mac with Apple chip (M1, M2, M3, M4 - all Macs since late 2020)
  • Mac with Intel chip (older Macs)

Windows:

  • Download the .exe installer (64-bit recommended)

Linux:

  • Download the .tar.gz archive for your architecture

Step 2: Install Android Studio

macOS

  1. Open the downloaded .dmg file
  2. Drag Android Studio to the Applications folder
  3. Launch Android Studio from the Applications folder
  4. If prompted with a security warning, click Open

Windows

  1. Run the downloaded .exe installer
  2. Follow the installation wizard
  3. Choose installation location (default is recommended)
  4. Select whether to import previous settings

Linux

  1. Extract the .tar.gz archive:

    tar -xzf android-studio-*.tar.gz
    
  2. Move to /opt (optional but recommended):

    sudo mv android-studio /opt/
    
  3. Run the studio script:

    /opt/android-studio/bin/studio.sh
    
  4. Optionally create a desktop entry via Tools → Create Desktop Entry

Step 3: Complete Setup Wizard (All Platforms)

  1. Follow the Setup Wizard:
    • Choose Standard installation for most users
    • Accept license agreements for SDK components
    • Wait for the SDK and additional components to download

Step 4: Verify Installation

After installation completes:

  1. Android Studio opens to the Welcome screen
  2. You should see options like “New Project”, “Open”, and “More Actions”
  3. The Android SDK is installed at:
    • macOS: ~/Library/Android/sdk
    • Windows: %LOCALAPPDATA%\Android\Sdk
    • Linux: ~/Android/Sdk

Source: Android Developers - Install Android Studio

Cloning and Building the EUDI Wallet

Step 1: Clone the wallet Repository

git clone https://github.com/eu-digital-identity-wallet/eudi-app-android-wallet-ui.git
cd eudi-app-android-wallet-ui

Step 2: Open in Android Studio

  1. Launch Android Studio
  2. Click Open
  3. Navigate to the cloned eudi-app-android-wallet-ui folder
  4. Click Open
  5. Wait for Gradle sync to complete (this may take several minutes on first run)

Step 3: Select Build Variant

The EUDI Wallet has different build configurations:

Product Flavors:

  • Dev - Connects to development environment services
  • Demo - Connects to demo environment services

Build Types:

  • Debug - Full logging enabled (recommended for development)
  • Release - No logging (production-ready)

To select a build variant:

  1. Go to Build → Select Build Variant
  2. In the Build Variants panel, find the :app module
  3. Click the dropdown under “Active Build Variant”
  4. Select your preferred variant (e.g., demoDebug for testing)

Step 4: Build the Project

  1. Go to Build → Make Project (or press Cmd + F9)
  2. Wait for the build to complete
  3. Check the Build output window for any errors

Source: EUDI Wallet - How to Build

Setting Up an Android Emulator

An Android emulator allows you to run Android apps on your Mac without a physical device.

Step 1: Open Virtual Device Manager

  1. In Android Studio, click More Actions on the Welcome screen
    • Or go to Tools → Device Manager if a project is open
  2. Click Virtual Device Manager

Step 2: Create a Virtual Device

  1. Click Create Virtual Device (or the + button)
  2. Choose a device definition:
    • Select a phone like Pixel 7 or Pixel 8
    • Devices with the Play Store icon (▶️) support Google Play Services
    • For EUDI Wallet, choose a device with Play Store support
  3. Click Next

Step 3: Select a System Image

  1. Choose an Android version:
    • API 34 (Android 14) or higher is recommended
    • Ensure the ABI matches your Mac:
      • arm64-v8a for Apple Silicon Macs (M1/M2/M3)
      • x86_64 for Intel Macs
  2. Click Download if the image isn’t already installed
  3. Wait for the download to complete, then click Next

Step 4: Configure the Emulator

  1. Give your virtual device a name (optional)
  2. Adjust advanced settings if needed:
    • RAM: 2048 MB minimum, 4096 MB recommended
    • VM Heap: 512 MB minimum
    • Graphics: Hardware (for better performance)
  3. Click Finish

Step 5: Launch the Emulator

  1. In the Device Manager, find your virtual device
  2. Click the Play button (▶️) to start the emulator
  3. Wait for Android to boot (first boot takes longer)

Source: Android Developers - Create and Manage Virtual Devices

Running on a Physical Device

Running on a physical Android device provides the most accurate testing experience.

Step 1: Enable Developer Mode

See the Enabling Developer Mode section below.

Step 2: Enable USB Debugging

  1. On your Android device, go to Settings → Developer options
  2. Enable USB debugging
  3. Optionally enable Install via USB for APK installation

Step 3: Connect Your Device

  1. Connect your Android device to your Mac via USB
  2. On your Android device, a prompt appears asking to Allow USB debugging
  3. Check Always allow from this computer (optional)
  4. Tap Allow

Step 4: Verify Connection

  1. In Android Studio, your device should appear in the device dropdown (top toolbar)

  2. Alternatively, run in Terminal:

    ~/Library/Android/sdk/platform-tools/adb devices
    
  3. You should see your device listed

Step 5: Run the App

  1. Select your device from the device dropdown
  2. Click Run (▶️) or press Ctrl + R
  3. The app will be installed and launched on your device

Source: Android Developers - Run Apps on a Hardware Device

Enabling Developer Mode on Android

Developer Mode unlocks advanced options required for app development and APK installation.

Steps to Enable Developer Mode

  1. Open Settings on your Android device
  2. Scroll down and tap About phone (or About device)
  3. Find Build number (may be under Software information on Samsung devices)
  4. Tap “Build number” 7 times in quick succession
  5. You’ll see messages counting down: “You are now X steps away from being a developer”
  6. After 7 taps, you’ll see: “You are now a developer!”
  7. If prompted, enter your device PIN or password

Access Developer Options

After enabling Developer Mode:

  1. Go back to Settings
  2. Scroll down to find Developer options (usually near the bottom)
  3. Tap to open and configure developer settings

Key Developer Options

OptionDescription
USB debuggingRequired for connecting to Android Studio
Install via USBAllow APK installation over USB
Stay awakeScreen stays on while charging (useful during development)
Select debug appChoose which app to debug
OEM unlockingRequired for bootloader unlocking (advanced)

Source: Android Developers - Configure On-Device Developer Options

Installing External APKs

You can install APK files (Android Package files) from external sources like GitHub releases.

Important: Before installing external APKs on an emulator or physical device, you must first enable Developer Mode. This unlocks the ability to install apps from unknown sources.

The simplest method - no ADB commands required:

  1. Start the Android emulator
  2. Enable Developer Mode on the emulator (tap Build Number 7 times in Settings > About)
  3. Open Chrome on the emulator
  4. Navigate to EUDI Wallet Releases
  5. Tap the APK file to download (e.g., app-demo-debug.apk)
  6. Once downloaded, tap the notification or open from Downloads
  7. Tap Install when prompted
  8. If asked about “Install unknown apps”, enable it for Chrome and retry

Method 2: Using ADB (Android Debug Bridge)

This is the most reliable method for development:

# Navigate to your platform-tools directory (or add it to PATH)
cd ~/Library/Android/sdk/platform-tools

# Install an APK file
./adb install /path/to/your-app.apk

# For emulator, use the -e flag
./adb -e install /path/to/your-app.apk

# For specific device, use the -s flag with device serial
./adb -s <device_serial> install /path/to/your-app.apk

# Force reinstall (overwrite existing app)
./adb install -r /path/to/your-app.apk

Method 2: Drag and Drop (Emulator Only)

For Android emulators:

  1. Start the emulator
  2. Download the APK file to your Mac
  3. Drag and drop the APK file onto the emulator window
  4. The APK will be installed automatically

Method 3: Using Device File Manager

  1. Transfer the APK to your device (via USB, cloud storage, or download)
  2. Open a file manager app on your Android device
  3. Navigate to the APK file
  4. Tap the APK to install
  5. If prompted, enable “Install from unknown sources”

Installing EUDI Wallet APK from GitHub Releases

  1. Go to EUDI Wallet Releases
  2. Download the latest APK file (e.g., app-demo-debug.apk)
  3. Install using one of the methods above

Source: Android Developers - ADB Commands

Debugging a Webapp with Android Studio

You can debug web applications running in Chrome on Android using Chrome DevTools and Android Studio.

Method 1: Chrome DevTools Remote Debugging

This is the most common method for debugging webapps:

Step 1: Enable USB Debugging

  1. Enable Developer Mode on your Android device (see above)
  2. Enable USB debugging in Developer options
  3. Connect your device via USB

Step 2: Enable Remote Debugging in Chrome

  1. Open Chrome on your Android device

  2. Navigate to your webapp URL

  3. On your Mac, open Chrome and go to:

    chrome://inspect/#devices
    
  4. Your Android device should appear with open tabs listed

  5. Click Inspect next to the tab you want to debug

  6. Chrome DevTools opens, connected to your mobile Chrome

Features Available

  • Elements panel: Inspect and modify DOM
  • Console: View logs and run JavaScript
  • Network: Monitor network requests
  • Sources: Debug JavaScript with breakpoints
  • Performance: Profile rendering performance
  • Application: Inspect storage, service workers, etc.

Method 2: Android Studio’s Chrome Tab Debugging

Android Studio can also connect to Chrome tabs:

  1. Connect your Android device
  2. In Android Studio, go to View → Tool Windows → App Inspection
  3. Select your device
  4. Choose the Chrome process
  5. Use the inspection tools to debug

Method 3: Debugging WebViews in Native Apps

If your webapp runs inside an Android app’s WebView:

  1. The app must enable WebView debugging:

    WebView.setWebContentsDebuggingEnabled(true);
    
  2. Connect your device via USB

  3. Open chrome://inspect/#devices in Chrome on your Mac

  4. WebViews appear separately from Chrome tabs

  5. Click Inspect to debug

Debugging Tips

ScenarioSolution
Device not appearingEnsure USB debugging is enabled; try different USB cable
Slow connectionUse USB 3.0 port; close unnecessary DevTools panels
Cannot inspect HTTPSEnsure valid/trusted certificates or use --ignore-certificate-errors
Emulator debuggingUse 10.0.2.2 instead of localhost to access host machine

Sources:

Viewing EUDI Wallet Logs

When debugging OpenID4VP flows or diagnosing wallet errors, you can inspect the EUDI Wallet’s runtime logs using Logcat — Android’s standard logging system. The easiest approach is to use the Logcat tab built into Android Studio (located in the bottom panel): select your emulator from the device dropdown, then filter by the wallet’s package name eu.europa.ec.euidiw to isolate its output. You can further narrow the results by searching for tags such as OpenId4Vp, PresentationManager, or WalletCore. Alternatively, you can use adb from the command line:

# Stream logs for the EUDI Wallet process only
adb logcat --pid=$(adb shell pidof eu.europa.ec.euidiw)

# Or filter by relevant tags
adb logcat -s "OpenId4VpManager" "PresentationManager" "WalletCore"

# Or grep for wallet-related keywords across all logs
adb logcat | grep -iE "eudi|openid4vp|presentation|mdoc|wallet"

Tip: If you installed adb via Android Studio, the binary is located at ~/Library/Android/sdk/platform-tools/adb. Add it to your PATH for convenience.

Adding Trusted Verifier Certificates

When using the x509_san_dns client ID scheme for OpenID4VP, the EUDI Wallet validates the verifier’s x5c certificate chain against a built-in Reader Trust Store. By default, this trust store only contains EU PID Issuer CA certificates (e.g., pidissuerca02_eu, dc4eu, r45_staging). If your verifier uses a certificate signed by a different CA — such as Let’s Encrypt — the wallet will reject the request with:

CERTIFICATE_PATH_ERROR: Trust anchor for certification path not found.
Invalid resolution: InvalidJarJwt(cause=Untrusted x5c)

To fix this, you must build the wallet from source with your CA’s root certificate added to the trust store.

Step 1: Identify Your Root CA

Determine which root CA signed your verifier’s certificate. For a Let’s Encrypt certificate, inspect the chain:

openssl x509 -in your_fullchain.pem -noout -issuer
# issuer= /C=US/O=Let's Encrypt/CN=E7

# The E7 intermediate is signed by ISRG Root X1

Let’s Encrypt uses two root CAs:

  • ISRG Root X1 (RSA) — signs E-series and R-series intermediates (via cross-sign)
  • ISRG Root X2 (ECDSA) — signs E-series intermediates natively

Step 2: Download the Root CA Certificates

# Download ISRG Root X1
curl -s https://letsencrypt.org/certs/isrgrootx1.pem \
  -o resources-logic/src/main/res/raw/isrg_root_x1.pem

# Download ISRG Root X2
curl -s https://letsencrypt.org/certs/isrg-root-x2.pem \
  -o resources-logic/src/main/res/raw/isrg_root_x2.pem

Verify the downloaded certificates:

openssl x509 -in resources-logic/src/main/res/raw/isrg_root_x1.pem -noout -subject -issuer
# subject= /C=US/O=Internet Security Research Group/CN=ISRG Root X1
# issuer= /C=US/O=Internet Security Research Group/CN=ISRG Root X1

Step 3: Update the Reader Trust Store Configuration

Edit the WalletCoreConfigImpl.kt for your build flavor (e.g., demo):

core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.kt

Add your root certificates to the configureReaderTrustStore call:

configureReaderTrustStore(
    context,
    R.raw.pidissuerca02_cz,
    R.raw.pidissuerca02_ee,
    R.raw.pidissuerca02_eu,
    R.raw.pidissuerca02_lu,
    R.raw.pidissuerca02_nl,
    R.raw.pidissuerca02_pt,
    R.raw.pidissuerca02_ut,
    R.raw.dc4eu,
    R.raw.r45_staging,
    R.raw.isrg_root_x1,   // Let's Encrypt RSA root
    R.raw.isrg_root_x2    // Let's Encrypt ECDSA root
)

Step 4: Build and Install

./gradlew :app:installDemoDebug

This will build and install the updated wallet on your connected emulator or device. The wallet will now accept verifier certificates signed by Let’s Encrypt.

Note: If you use a different CA (e.g., your own self-signed root CA), follow the same steps: place your root CA .pem file in resources-logic/src/main/res/raw/ and add its resource reference to configureReaderTrustStore(). Only root CA certificates need to be added — intermediates are validated through the x5c chain provided in the JAR JWT header. Source: EUDI Wallet Configuration Guide

Troubleshooting

Common Issues

Gradle Sync Failed

Error: Could not find com.android.tools.build:gradle:X.X.X

Solution:

  1. Check your internet connection
  2. Go to File → Invalidate Caches → Invalidate and Restart
  3. Try File → Sync Project with Gradle Files

Emulator Won’t Start

On Apple Silicon Macs:

  • Ensure you downloaded an ARM64 system image
  • Check Rosetta 2 is installed: softwareupdate --install-rosetta

General:

  • Increase RAM allocation in emulator settings
  • Close other memory-intensive applications
  • Try cold boot: Device Manager → Right-click device → Cold Boot Now

ADB Device Not Found

# Restart ADB server
~/Library/Android/sdk/platform-tools/adb kill-server
~/Library/Android/sdk/platform-tools/adb start-server

EUDI Wallet Build Errors

  • Ensure JDK 21 is configured: Android Studio → Preferences → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK
  • Try Build → Clean Project followed by Build → Rebuild Project

References

Official Documentation

EUDI Wallet Documentation

Additional Resources

Installing the Age Verification App on Android Studio

What is the Age Verification App?

The Age Verification (AV) App is the official wallet application for the EU Age Verification Solution, developed under the EU Digital Identity framework. It is purpose-built for age verification only, implementing the EU Age Verification Profile (Annex A).

The AV App allows users to:

  • Obtain Proof of Age attestations — via document scanning (NFC passport) or online issuance
  • Present proof of age — to Relying Parties requesting age verification
  • Preserve privacy — only reveals whether the user meets an age threshold, not their exact birth date

The AV App is a fork of the EUDI Wallet but configured specifically for the Annex A profile, with different trust settings, client ID schemes, and URL schemes.

The AV App Android source code is open source and available on GitHub.

Important: Age Verification App vs. EUDI Wallet

These are two separate wallet applications with fundamentally different profiles:

FeatureAge Verification AppEUDI Wallet
ProfileAnnex A (Age Verification)HAIP (High Assurance)
Client ID Schemeredirect_urix509_san_dns, x509_hash
JAR (Signed Requests)Not requiredRequired (JWT with x5c header)
URL Schemesav://, avsp://, openid4vp://eudi-openid4vp://, openid4vp://
CredentialsProof of Age onlyPID, mDL, various
Response Modedirect_postdirect_post.jwt
LoASubstantialHigh
PAR / DPoPDisabledSupported

If your Relying Party targets the EUDI Wallet using x509_hash (or legacy x509_san_dns) with signed JARs, see the EUDI Wallet guide instead.

ewQwe Demo Setup

This section describes how to run the AV App together with the ewQwe Relying Party Demo Webapp for end-to-end Annex A testing on a local Android emulator.

Repository: https://github.com/eu-digital-identity-wallet/av-app-android-wallet-ui

Prerequisites

Tip: Use the same EUDI_Dev_Device emulator as the HAIP wallet. Both wallet apps can be installed simultaneously on the same emulator, and demo.ewqwe.local will already be mapped to the host machine if you previously ran ./start_ewqwe_eudi_emulator.sh from the HAIP repo. See the EUDI Wallet ewQwe Demo Setup for emulator setup instructions.

Step 1: Clone the Repository

git clone https://github.com/eu-digital-identity-wallet/av-app-android-wallet-ui.git
cd av-app-android-wallet-ui

Step 2: Build and Run the App

  1. Open the project in Android Studio
  2. Select the app module and the EUDI_Dev_Device emulator (or any emulator with demo.ewqwe.local mapped)
  3. Select the devDebug build variant (Build → Select Build Variant)
  4. Click Run (▶️) to deploy and start the AV App

Step 3: Initialize Documents

Once the app is running:

  1. Follow the on-screen prompts to create a PIN code
  2. Obtain a Proof of Age credential from the dev issuer (https://test.issuer.dev.ageverification.dev)

Step 4: Open the Relying Party Demo Webapp

  1. Open Chrome on the Android emulator
  2. Navigate to https://demo.ewqwe.local:5174
  3. Proceed past the certificate warning (expected — the demo uses a self-signed certificate)
  4. The Demo Webapp should load

Step 5: Request Proof of Age

  1. From the webapp, select “Proof of Age” (Annex A profile) and initiate a credential request
  2. This displays a QR code or deep link that opens the AV App
  3. Thanks to the TLS trust bypass, the wallet accepts the self-signed certificate and proceeds
  4. Approve the credential sharing in the wallet
  5. The webapp displays the verified age verification result

Security Bypass (Technical Details)

The ewQwe fork adds a custom HttpClient configuration in NetworkModule.kt that trusts all TLS certificates and skips hostname verification:

val trustAllCerts = arrayOf<TrustManager>(
    object : X509TrustManager {
        override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
        override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
        override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
    }
)
// HttpClient configured with sslManager using trustAllCerts + HostnameVerifier { _, _ -> true }

The Annex A profile uses redirect_uri as client ID scheme — no x5c certificate chain or Reader Trust Store is involved, so no reader trust bypass is needed (unlike the HAIP wallet).


Quick Start: Download APK Directly (No Build Required)

The easiest way to install the AV App is to download the pre-built APK:

  1. Install Android Studio and create an emulator (see EUDI Wallet guide — Setting Up an Android Emulator)
  2. Enable Developer Mode on the emulator (see EUDI Wallet guide — Enabling Developer Mode)
  3. Open Chrome on the emulator
  4. Navigate to AV App Releases
  5. Download the latest APK (e.g., app-dev-debug.apk)
  6. Open the downloaded file and tap Install

Note: Use the dev variant for testing with the development infrastructure, or demo for the demo environment. Both flavors support redirect_uri client ID scheme.


Annex A Profile: What Your RP Needs to Know

The AV App implements the Annex A profile, which is significantly simpler than the HAIP profile used by the EUDI Wallet. Here are the key implications for your Relying Party:

Client ID Scheme: redirect_uri

Per Annex A Section A.5, the client identifier scheme MUST be redirect_uri:

“The client identifier scheme MUST be redirect_uri followed by the response_uri

This means:

  • No signed JAR required — the Authorization Request is sent as plain query parameters
  • No x5c certificate chain — the wallet identifies the RP by its redirect URI, not a certificate
  • No Reader Trust Store modification — you do not need to add your CA to the wallet’s trust store
  • Simpler implementation — no JWT signing infrastructure needed

The client_id in your Authorization Request must be the literal redirect_uri: prefix followed by the response_uri:

client_id=redirect_uri:https://your-rp.example.com/openid4vp/callback
response_uri=https://your-rp.example.com/openid4vp/callback

URL Schemes for Same-Device Flow

The AV App registers the following URL schemes for deep-linking (same-device flow):

SchemeVariablePurpose
av://AV_SCHEMEPrimary AV App scheme
avsp://AVSP_SCHEMEAV Service Provider scheme
openid4vp://OPENID4VP_SCHEMEStandard OpenID4VP scheme
eudi-openid4vp://EUDI_OPENID4VP_SCHEMEEUDI-compatible scheme
mdoc-openid4vp://MDOC_OPENID4VP_SCHEMEmDoc presentation scheme

For same-device flows, construct a deep link URL using one of these schemes:

av://?client_id=redirect_uri:https://rp.example.com/cb&response_uri=https://rp.example.com/cb&...

Response Mode: direct_post

The AV App uses response_mode=direct_post (not direct_post.jwt as in HAIP). The wallet POSTs the VP Token directly to the response_uri as plain form parameters — not wrapped in a JWT.

Credential Format

The AV App works with:

  • Document type: eu.europa.ec.av.1 (Age Verification namespace)
  • Credential format: mDoc (ISO 18013-5 / CBOR encoded)
  • Key claim: age_over_18 (boolean)

PAR and DPoP: Disabled

Per the Annex A profile, both Pushed Authorization Requests (PAR) and DPoP are explicitly disabled:

  • parUsage = NEVER
  • useDPoPIfSupported = false / DPoPUsage.Disabled

Building from Source

The AV App build process is identical to the EUDI Wallet. Follow the same Android Studio setup guide, but clone the AV App repository instead:

git clone https://github.com/eu-digital-identity-wallet/av-app-android-wallet-ui.git
cd av-app-android-wallet-ui

Build Variants (Flavors)

The AV App has two build flavors:

FlavorIssuer URLPurpose
devhttps://test.issuer.dev.ageverification.devDevelopment / testing
demohttps://issuer.ageverification.devDemo environment

Build and install:

# Dev flavor
./gradlew :app:installDevDebug

# Demo flavor
./gradlew :app:installDemoDebug

AV App Configuration

The AV App configuration is centralized in WalletCoreConfigImpl.kt, with one implementation per flavor:

  • core-logic/src/dev/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.kt
  • core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.kt

OpenID4VP Configuration (Default)

Both flavors are pre-configured for Annex A:

configureOpenId4Vp {
    withClientIdSchemes(
        listOf(
            ClientIdScheme.RedirectUri  // Annex A mandated scheme
        )
    )
    withSchemes(
        listOf(
            BuildConfig.OPENID4VP_SCHEME,      // openid4vp://
            BuildConfig.EUDI_OPENID4VP_SCHEME,  // eudi-openid4vp://
            BuildConfig.MDOC_OPENID4VP_SCHEME,  // mdoc-openid4vp://
            BuildConfig.AVSP_SCHEME,            // avsp://
            BuildConfig.AV_SCHEME               // av://
        )
    )
    withFormats(
        Format.MsoMdoc.ES256
    )
}

Using the Preregistered Client Scheme (Optional)

If your verifier requires a pre-registered client ID (e.g., for self-signed certificate setups), add the Preregistered scheme to the configuration:

const val OPENID4VP_VERIFIER_API_URI = "https://your-verifier.example.com"
const val OPENID4VP_VERIFIER_LEGAL_NAME = "Your Verifier"
const val OPENID4VP_VERIFIER_CLIENT_ID = "your-client-id"

configureOpenId4Vp {
    withClientIdSchemes(
        listOf(
            ClientIdScheme.Preregistered(
                listOf(
                    PreregisteredVerifier(
                        clientId = OPENID4VP_VERIFIER_CLIENT_ID,
                        verifierApi = OPENID4VP_VERIFIER_API_URI,
                        legalName = OPENID4VP_VERIFIER_LEGAL_NAME
                    )
                )
            )
        )
    )
}

Working with Self-Signed Certificates

If your verifier or issuer uses self-signed certificates (development only), see the official AV App configuration guide for instructions on configuring a custom HttpClient that trusts all certificates.

⚠️ Security Warning: Trusting all certificates disables TLS verification and must never be used in production.

Passport Scanning Issuance

The AV App supports issuing age verification credentials by scanning a physical passport via NFC. This requires a second issuer configuration:

override val passportScanningIssuerConfig: OpenId4VciManager.Config =
    OpenId4VciManager.Config.Builder()
        .withIssuerUrl(issuerUrl = "https://passport.issuer.dev.ageverification.dev")
        .withClientAuthenticationType(
            OpenId4VciManager.ClientAuthenticationType.None(
                clientId = "wallet-dev"
            )
        )
        .withAuthFlowRedirectionURI(BuildConfig.ISSUE_AUTHORIZATION_DEEPLINK)
        .withParUsage(OpenId4VciManager.Config.ParUsage.NEVER)
        .withDPoPUsage(OpenId4VciManager.Config.DPoPUsage.Disabled)
        .build()

The passport scanning flow includes face liveness detection and face matching, configured via faceMatchConfig.

Viewing Logs

Logs from the AV App can be viewed using Logcat in Android Studio, identical to the EUDI Wallet. See Viewing EUDI Wallet Logs for instructions.

Filter by the EudiWallet tag to see wallet-core events:

tag:EudiWallet

Key Differences from the EUDI Wallet (Summary)

AspectAV App (Annex A)EUDI Wallet (HAIP)
Trust modelNo trust lists needed; redirect_uri identifies the RPRequires root CA in Reader Trust Store
Request signingPlain query parametersSigned JAR with x5c certificate chain
Certificate requirementsStandard HTTPS (any CA)Specific CA must be trusted by wallet
Implementation complexityLow — no JWT signing neededHigh — requires PKCS#8 keys, x5c chain
Credential typeseu.europa.ec.av.1 (age verification)org.iso.18013.5.1.mDL, PID, various
Privacy featuresAge threshold only (age_over_18)Selective disclosure of any attribute
ZKP supportYes (Longfellow circuits)Not in current reference implementation
DC APIEnabledEnabled

References

Official AV App Documentation

Age Verification Specifications

Issuer Services

Running the Credential Verifier and the EUDI Wallet on a Local Network

This guide explains how to run a credential verifier server and the EUDI Android Wallet side-by-side on a local network using your own certificates, without any dependency on the European Commission’s hosted infrastructure.


Table of Contents

  1. Architecture Overview
  2. Would x509_san_hash Help?
  3. Prerequisites
  4. Step 1 – Generate Certificates for x509_san_hash
  5. Step 2 – Configure the Credential Verifier
  6. Step 3 – Make the Verifier Reachable from the Android Device
  7. Step 4 – Configure the Wallet
  8. Step 5 – Build and Run the Wallet
  9. Step 6 – End-to-End Test
  10. Choosing Between x509_san_dns and x509_san_hash
  11. Troubleshooting
  12. Security Notes

Architecture Overview

graph TD
    subgraph Mac["Developer machine (macOS)"]
        Verifier["credential_verifier\n(HTTPS :9443)\nserves QR code + OpenID4VP endpoint"]
    end

    subgraph Network["Local Wi-Fi network"]
        direction LR
        Verifier <-->|"HTTPS (JAR + vp_token POST)"| Wallet
    end

    subgraph Phone["Android device / emulator"]
        Wallet["EUDI Wallet\n(debug build)"]
    end

Flow summary:

sequenceDiagram
    participant Browser as Browser on Mac
    participant Verifier as Credential Verifier (Mac :9443)
    participant Wallet as EUDI Wallet (Android)

    Browser->>Verifier: Open UI – select credential type
    Verifier-->>Browser: Render QR code (haip-vp:// URI)

    Note over Wallet: User scans QR with wallet camera

    Wallet->>Verifier: GET request_uri (fetch signed JAR)
    Verifier-->>Wallet: Signed Request Object JWT<br/>(x5c contains local cert chain)

    Wallet->>Wallet: Validate JAR<br/>x509_san_hash: hash(x5c[0]) == client_id ✓<br/>ProfileValidation: mdlReaderAuth OID ✓

    Wallet->>Wallet: Show consent screen
    Wallet->>Wallet: User approves

    Wallet->>Verifier: POST response_uri<br/>{ vp_token, presentation_submission }
    Verifier-->>Wallet: 200 OK

    Verifier-->>Browser: WebSocket: presentation verified ✓

Would x509_san_hash Help?

Yes – x509_san_hash is the recommended approach for local-network development.

Concernx509_san_dnsx509_san_hash
CA certificate must be bundled in the wallet APKYes (rebuild required)No
Hostname must be resolvable from the deviceYes (DNS or hosts trick)No hostname check
Self-signed cert acceptedOnly if CA is in ReaderTrustStoreYes
Certificate rotation breaks client_idNoYes (hash changes)
Works behind NAT / private IPExtra DNS mapping neededYes

With x509_san_hash:

  1. Generate a self-signed or private-CA cert for the verifier (see Step 1).
  2. Compute its SHA-256 fingerprint → that becomes client_id.
  3. ClientIdScheme.X509Hash is already enabled in the default WalletCoreConfigImpl — no code change needed.
  4. No CA needs to be added to ReaderTrustStore and no hostname matching is required.

Important caveat (wallet-core ≤ 0.25): Even with x509_san_hash, the leaf certificate still passes through ProfileValidation (ISO 18013-5) because the wallet shares one ReaderTrustStore between the BLE proximity path and the remote OpenID4VP path. The leaf certificate must still:

  • Include Extended Key Usage mdlReaderAuth OID 1.0.18013.5.1.6
  • Have valid SKI and AKI (AKI must reference the issuer’s SKI)
  • Use ECDSA (P-256 / P-384 / P-521), not RSA
  • Have a validity period ≤ 1 187 days
  • Include CN in the Subject

See eudi-lib-android-wallet-core#247.


Prerequisites

ToolNotes
openssl ≥ 3.xbrew install openssl@3
Android Studio Meerkat+For building the wallet
Android device or emulator (API 29+)Physical device recommended for QR scanning

Step 1 – Generate Certificates for x509_san_hash

Save the following script as generate_local_certs.sh and run it from your verifier project directory. It generates:

  • A root CA (needed in ReaderTrustStore because of the shared trust-store limitation above).
  • A leaf certificate that satisfies all ISO 18013-5 ProfileValidation rules and includes TLS SANs.
  • Prints the x509_san_hash client_id value you need to configure in the verifier.
#!/usr/bin/env bash
# generate_local_certs.sh
#
# Generates a Root CA + leaf certificate suitable for:
#   - x509_san_hash client_id scheme (EUDI wallet)
#   - HTTPS TLS (serverAuth EKU)
#   - ISO 18013-5 ProfileValidation (mdlReaderAuth OID, AKI/SKI, ECDSA, ≤1187 days)
#
# Usage:
#   chmod +x generate_local_certs.sh
#   ./generate_local_certs.sh [output_dir]
#
# Output:
#   <output_dir>/root.ca.pem          ← Add to wallet ReaderTrustStore
#   <output_dir>/server.key.pem       ← Private key for verifier
#   <output_dir>/server.cert.pem      ← Leaf certificate
#   <output_dir>/server.fullchain.pem ← leaf + root (use as x509_cert_path in verifier)
#
# The script prints the x509_san_hash client_id value at the end.

set -euo pipefail

OUT_DIR="${1:-local_certs}"
mkdir -p "$OUT_DIR"

# Detect local Wi-Fi IP (macOS: en0; Linux: first non-lo interface)
if [[ "$(uname)" == "Darwin" ]]; then
    LOCAL_IP="$(ipconfig getifaddr en0 2>/dev/null || echo "127.0.0.1")"
else
    LOCAL_IP="$(hostname -I | awk '{print $1}')"
fi

echo "=== Local IP: $LOCAL_IP ==="
echo "=== Output dir: $OUT_DIR ==="
echo ""

# ── 1. Root CA ─────────────────────────────────────────────────────────────────
echo "[1/4] Generating Root CA key and certificate..."

openssl genpkey \
    -algorithm EC \
    -pkeyopt ec_paramgen_curve:prime256v1 \
    -out "$OUT_DIR/root.key.pem"

openssl req -new -x509 \
    -days 1187 \
    -key "$OUT_DIR/root.key.pem" \
    -out "$OUT_DIR/root.ca.pem" \
    -subj "/CN=Local EUDI Verifier Root CA" \
    -addext "basicConstraints = critical, CA:TRUE, pathlen:0" \
    -addext "keyUsage = critical, keyCertSign, cRLSign" \
    -addext "subjectKeyIdentifier = hash"

echo "    ✓ root.ca.pem"

# ── 2. Leaf key ────────────────────────────────────────────────────────────────
echo "[2/4] Generating leaf key..."

openssl genpkey \
    -algorithm EC \
    -pkeyopt ec_paramgen_curve:prime256v1 \
    -out "$OUT_DIR/server.key.pem"

echo "    ✓ server.key.pem"

# ── 3. CSR ────────────────────────────────────────────────────────────────────
echo "[3/4] Generating CSR..."

openssl req -new \
    -key "$OUT_DIR/server.key.pem" \
    -out "$OUT_DIR/server.csr" \
    -subj "/CN=Local EUDI Verifier"

# Extensions file
# OID 1.0.18013.5.1.6 = mdlReaderAuth (REQUIRED by wallet ProfileValidation)
# OID 1.3.6.1.5.5.7.3.1 = serverAuth  (for HTTPS TLS)
# OID 1.3.6.1.5.5.7.3.2 = clientAuth  (optional but harmless)
cat > "$OUT_DIR/leaf_ext.cnf" <<EOF
[v3_leaf]
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always
keyUsage               = critical, digitalSignature
extendedKeyUsage       = 1.3.6.1.5.5.7.3.1, 1.3.6.1.5.5.7.3.2, 1.0.18013.5.1.6
basicConstraints       = CA:FALSE
subjectAltName         = DNS:localhost, IP:127.0.0.1, IP:${LOCAL_IP}
EOF

echo "    ✓ server.csr"

# ── 4. Sign leaf with root CA ─────────────────────────────────────────────────
echo "[4/4] Signing leaf certificate with Root CA..."

openssl x509 -req \
    -sha256 \
    -days 1187 \
    -in "$OUT_DIR/server.csr" \
    -CA "$OUT_DIR/root.ca.pem" \
    -CAkey "$OUT_DIR/root.key.pem" \
    -CAcreateserial \
    -out "$OUT_DIR/server.cert.pem" \
    -extfile "$OUT_DIR/leaf_ext.cnf" \
    -extensions v3_leaf

echo "    ✓ server.cert.pem"

# Full chain: leaf first, then root (required by wallet x5c)
cat "$OUT_DIR/server.cert.pem" "$OUT_DIR/root.ca.pem" > "$OUT_DIR/server.fullchain.pem"
echo "    ✓ server.fullchain.pem"

# ── Print verification ─────────────────────────────────────────────────────────
echo ""
echo "=== Certificate details ==="
openssl x509 -in "$OUT_DIR/server.cert.pem" -noout -subject -dates
echo ""
echo "=== Extended Key Usage (must include 1.0.18013.5.1.6 = mdlReaderAuth) ==="
openssl x509 -in "$OUT_DIR/server.cert.pem" -noout -text | grep -A4 "Extended Key"

# ── Compute x509_san_hash client_id ──────────────────────────────────────────
HASH=$(openssl x509 -in "$OUT_DIR/server.cert.pem" -outform DER \
    | openssl dgst -sha256 -binary \
    | openssl base64 -A \
    | tr '+/' '-_' \
    | tr -d '=')

echo ""
echo "╔══════════════════════════════════════════════════════════════════════╗"
echo "║  x509_san_hash client_id (paste into verifier config)               ║"
echo "╠══════════════════════════════════════════════════════════════════════╣"
echo "║  x509_san_hash:${HASH}"
echo "╚══════════════════════════════════════════════════════════════════════╝"
echo ""
echo "Next steps:"
echo "  1. Copy ${OUT_DIR}/root.ca.pem to"
echo "     eudi-app-android-wallet-ui/resources-logic/src/main/res/raw/local_verifier_root_ca.pem"
echo "  2. Configure verifier: x509_cert_path = \"${OUT_DIR}/server.fullchain.pem\""
echo "  3. Configure verifier: client_id = \"x509_san_hash:${HASH}\""
echo "  4. Rebuild the wallet (see Step 4 of local_network.md)"

Run it:

chmod +x generate_local_certs.sh
./generate_local_certs.sh

Step 2 – Configure the Credential Verifier

Edit credential-server.toml (or your verifier’s equivalent config file):

# credential-server.toml  –  local development override

host_name = "0.0.0.0"
host_port = 9443

# Public URL reachable from the Android device (use your Mac's Wi-Fi IP)
public_root_url = "https://192.168.1.42:9443"

disable_authentication = true

[tls_params]
server_private_key  = "local_certs/server.key.pem"
server_certificate  = "local_certs/server.cert.pem"
server_ca_chain     = "local_certs/root.ca.pem"

[openid4vp_config.haip_config]
# Full chain used to sign the JAR (Authorization Request Object) – leaf first
x509_cert_path = "local_certs/server.fullchain.pem"
x509_key_path  = "local_certs/server.key.pem"

# Paste the value printed by generate_local_certs.sh
client_id = "x509_san_hash:<hash printed by the script>"

Start the verifier:

cargo run --bin credential-verifier -- --config credential-server.toml

Step 3 – Make the Verifier Reachable from the Android Device

graph LR
    subgraph Mac["Mac (Wi-Fi IP: 192.168.1.42)"]
        V["Verifier :9443"]
    end
    subgraph Router["Local Wi-Fi router"]
    end
    subgraph Device["Android device"]
        W["EUDI Wallet"]
    end

    V <-->|"Wi-Fi"| Router
    Router <-->|"Wi-Fi"| W
  1. Find your Mac’s Wi-Fi IP:

    ipconfig getifaddr en0
    # e.g. 192.168.1.42
    
  2. Make sure the Android device is on the same Wi-Fi network as the Mac.

  3. Open the verifier’s firewall port (macOS):

    # Allow incoming connections on port 9443
    # System Settings → Network → Firewall → Options → Add credential-verifier
    
  4. Verify connectivity from the device: Open https://192.168.1.42:9443 in the Android browser. You’ll see a certificate warning (expected — the device doesn’t trust the local CA yet), but the connection should reach the verifier.

With x509_san_hash, no DNS configuration is needed on the Android device. The IP address in public_root_url is sufficient for the wallet to reach the verifier, and the wallet validates the certificate by its hash rather than its hostname.

Android Emulator Note

If using an emulator, the Mac’s IP is reachable via the special address 10.0.2.2:

public_root_url = "https://10.0.2.2:9443"

The leaf_ext.cnf in Step 1 already includes IP:127.0.0.1 and IP:<LOCAL_IP> in the SAN. Add IP:10.0.2.2 if you need to support both physical device and emulator simultaneously.


Step 4 – Configure the Wallet

4a – Add the Root CA to ReaderTrustStore

Even with x509_san_hash, the wallet still runs ProfileValidation on the leaf certificate (shared trust store limitation — see the caveat above). The root CA must therefore be known to the wallet.

Copy the generated root CA into the wallet’s raw resources:

cp local_certs/root.ca.pem \
   eudi-app-android-wallet-ui/resources-logic/src/main/res/raw/local_verifier_root_ca.pem

Add it to WalletCoreConfigImpl in the flavour you are testing (demo or dev):

// core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.kt

configureReaderTrustStore(
    context,
    R.raw.pidissuerca02_eu,   // existing entries …
    // …
    R.raw.local_verifier_root_ca   // ← add this
)

4b – Allow Self-Signed TLS Certificates (HTTPS Transport)

The wallet’s Ktor HttpClient (in NetworkModule.kt) uses the Android system CA store for TLS. To allow connections to your local verifier’s self-signed TLS certificate, replace provideHttpClient with a dev-only trust-all variant:

// network-logic/src/main/java/…/NetworkModule.kt
// WARNING: only use this in debug/dev builds – never in production

@SuppressLint("TrustAllX509TrustManager", "CustomX509TrustManager")
@Single
fun provideHttpClient(json: Json): HttpClient {
    val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
        override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
        override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
        override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
    })
    return HttpClient(Android) {
        install(Logging) { level = LogLevel.ALL }
        install(ContentNegotiation) { json(json = json) }
        engine {
            sslManager = { conn ->
                conn.sslSocketFactory = SSLContext.getInstance("TLS").apply {
                    init(null, trustAllCerts, SecureRandom())
                }.socketFactory
                conn.hostnameVerifier = HostnameVerifier { _, _ -> true }
            }
        }
    }
}

A safer alternative: install only your local CA as a custom TrustManager instead of trusting all certificates.

4c – client_id_scheme Configuration

Both X509SanDns and X509Hash are already enabled in the default WalletCoreConfigImpl. No change is required for x509_san_hash support.


Step 5 – Build and Run the Wallet

cd eudi-app-android-wallet-ui
./gradlew installDemoDebug

Or use Android Studio → Run.


Step 6 – End-to-End Test

sequenceDiagram
    participant Browser as Browser (Mac)
    participant Verifier as Credential Verifier
    participant Wallet as EUDI Wallet (Android)

    Browser->>Verifier: Open https://192.168.1.42:9443
    Verifier-->>Browser: UI with credential type selection
    Browser->>Verifier: Select credential + Generate QR
    Verifier-->>Browser: QR code displayed

    Note over Wallet: User taps "Scan QR" in wallet

    Wallet->>Verifier: GET request_uri → Signed JAR
    Verifier-->>Wallet: JAR with x5c (local cert chain)

    Wallet->>Wallet: x509_san_hash: verify hash(x5c[0]) == client_id
    Wallet->>Wallet: ProfileValidation passes (mdlReaderAuth OID ✓)
    Wallet->>Wallet: Consent screen shown (verifier name from CN)
    Wallet->>Wallet: User approves

    Wallet->>Verifier: POST /response { vp_token, presentation_submission }
    Verifier-->>Wallet: 200 OK

    Verifier-->>Browser: Presentation verified ✓

Choosing Between x509_san_dns and x509_san_hash

Use x509_san_hash when:
  ✔ You have a self-signed or private-CA certificate
  ✔ You don't want to configure DNS on the Android device
  ✔ You are doing quick local development or integration testing
  ✔ The verifier's IP may change (the cert hash doesn't change with IP)

Use x509_san_dns when:
  ✔ You have a proper CA (internal or public) issuing verifier certificates
  ✔ You want certificate rotation to be transparent (CA trust persists)
  ✔ You are running a shared dev environment (multiple wallet builds connect)
  ✔ You are testing in a staging environment with a real domain

Troubleshooting

InvalidJarJwt(cause=Untrusted x5c)

  1. Check the Extended Key Usage — must include 1.0.18013.5.1.6:

    openssl x509 -in local_certs/server.cert.pem -text -noout | grep -A5 "Extended Key"
    

    If 1.0.18013.5.1.6 is missing, regenerate with the script in Step 1.

  2. Check AKI/SKI are present:

    openssl x509 -in local_certs/server.cert.pem -text -noout | grep -A3 "Subject Key\|Authority Key"
    
  3. Confirm the root CA is in ReaderTrustStore and the wallet was rebuilt.

  4. Confirm validity ≤ 1 187 days:

    openssl x509 -in local_certs/server.cert.pem -noout -dates
    

CertPathValidatorException: Trust anchor not found

  • Root CA is not in ReaderTrustStore and not in x5c.
  • Include root in x5c (use server.fullchain.pem for x509_cert_path).

ERR_CERT_AUTHORITY_INVALID in Browser

Expected — the browser doesn’t trust your local CA. The wallet handles TLS separately via the custom TrustManager (Step 4b). To silence the browser warning, install root.ca.pem in macOS Keychain.

Wallet Cannot Reach the Verifier

  • Check Mac firewall: System Settings → Network → Firewall — port 9443 must be open.
  • Both devices must be on the same Wi-Fi network (check for AP client isolation).
  • Test: curl -k https://192.168.1.42:9443/ from the Mac.

x509_san_hash client_id Mismatch

Recompute the hash and compare:

openssl x509 -in local_certs/server.cert.pem -outform DER \
    | openssl dgst -sha256 -binary \
    | openssl base64 -A \
    | tr '+/' '-_' \
    | tr -d '='

Security Notes

  • Never ship the trust-all TrustManager in a production build. Guard it with if (BuildConfig.DEBUG) or a debug-only source set.
  • The local CA and private keys generated by the script are for development only.
  • disable_authentication = true in credential-server.toml disables mutual TLS. Do not use this in production.

Last updated: April 2026

Relying Party Requirements for the EUDI Android Wallet

This document describes all the technical requirements a Relying Party (RP) / Verifier must fulfil for its OpenID4VP Authorization Requests to be accepted by the EUDI Android Wallet.


Table of Contents

  1. JOSE and X5C – Technical Background
  2. Supported Client Identifier Schemes
  3. x509_san_dns vs x509_san_hash
  4. X.509 Certificate Profile
  5. Trust Anchor Configuration in the Wallet
  6. Same-Device Flow
  7. Cross-Device OpenID4VP Flow
  8. Cross-Device Proximity Flow (ISO 18013-5 BLE/NFC)
  9. Credential Format Requirements
  10. Credential Revocation Behaviour
  11. Summary of Hard Requirements
  12. Common Error Messages and Their Causes
  13. Known Limitations and GitHub Issues
  14. Glossary
  15. Reference Specifications

1. JOSE and X5C – Technical Background

1.1 JSON Object Signing and Encryption (JOSE)

JOSE is the umbrella term for a family of IETF standards that define how to represent content protected by integrity, encryption, and key operations using JSON-based data structures. The four core RFCs are:

StandardRFCPurpose
JWS – JSON Web SignatureRFC 7515Represent signed content. Defines Compact, JSON Flattened, and JSON General serialisations.
JWE – JSON Web EncryptionRFC 7516Represent encrypted content.
JWK – JSON Web KeyRFC 7517Represent cryptographic keys as JSON.
JWT – JSON Web TokenRFC 7519Claims-based tokens as a JWS (or JWE) payload.
JWA – JSON Web AlgorithmsRFC 7518Registry of algorithms usable with JOSE (e.g. ES256).

An OpenID4VP Request Object is a JWT, i.e. a signed token whose header specifies the algorithm and key material (x5c), and whose payload carries the OpenID4VP claims (client_id, presentation_definition, nonce, etc.).

1.2 The x5c JOSE Header Parameter

Defined in RFC 7515 §4.1.6, the x5c parameter contains the X.509 certificate chain whose leaf certificate holds the public key used to verify the JWT’s signature.

JOSE Header
{
  "alg": "ES256",                      ← signing algorithm
  "typ": "oauth-authz-req+jwt",        ← media type
  "x5c": [
    "<base64(DER(leaf certificate))>", ← [0] leaf – public key for sig verification
    "<base64(DER(intermediate CA))>",  ← [1] optional intermediate(s)
    "<base64(DER(root CA))>"           ← [2] root (recommended, not strictly required by RFC)
  ]
}

Why does the wallet require x5c?

The HAIP profile mandates that the RP authenticates itself through an X.509 certificate chain embedded in x5c, rather than a pre-registered key. The wallet uses the x5c chain to:

  1. Verify the JWT signature using the public key in x5c[0] (the leaf certificate).
  2. Establish RP identity – either by matching the leaf’s DNS Subject Alternative Name against client_id (x509_san_dns) or by matching a SHA-256 hash of the leaf against client_id (x509_san_hash).
  3. Validate certificate chain trust using the wallet’s configured trust store (configureReaderTrustStore).
  4. Enforce ISO 18013-5 certificate profile rules via ProfileValidation (see Section 4).

1.3 JAR – JWT-Secured Authorization Request

The Request Object is specifically a JAR (RFC 9101), i.e. a JWT that contains all the parameters of an OAuth 2.0 / OpenID Authorization Request. The wallet fetches the JAR from request_uri (a URL reference pattern) or receives it inline in the request parameter. Unsigned (plain JSON) requests are rejected.


2. Supported Client Identifier Schemes

The wallet is configured in WalletCoreConfigImpl as follows (both demo and dev flavours):

configureOpenId4Vp {
    withClientIdSchemes(
        listOf(
            ClientIdScheme.X509SanDns,  // "x509_san_dns"
            ClientIdScheme.X509Hash     // "x509_san_hash"
        )
    )
    withSchemes(listOf(
        BuildConfig.OPENID4VP_SCHEME,       // "openid4vp"
        BuildConfig.EUDI_OPENID4VP_SCHEME,  // "eudi-openid4vp"
        BuildConfig.MDOC_OPENID4VP_SCHEME,  // "mdoc-openid4vp"
        BuildConfig.HAIP_OPENID4VP_SCHEME   // "haip-vp"
    ))
    withFormats(Format.MsoMdoc.ES256, Format.SdJwtVc.ES256)
}

The Preregistered scheme is supported at the library level but requires a static verifier entry in the wallet build — it is not enabled in production.


3. x509_san_dns vs x509_san_hash

Both schemes embed the signing certificate chain in x5c and require the same X.509 profile. The difference is how the wallet resolves and trusts the RP’s identity.

3.1 x509_san_dns

client_id = "x509_san_dns:<hostname>"
  • Trust mechanism — CA-based. The wallet validates the x5c chain against the trust anchors in ReaderTrustStore (configureReaderTrustStore).
  • Identity binding. The wallet verifies that the hostname embedded in client_id appears as a DNS Subject Alternative Name (SAN) in the leaf certificate (x5c[0]).
  • Implications:
    • The CA root certificate must be bundled in the wallet APK at build time.
    • The RP hostname must be resolvable from the Android device.
    • Certificate rotation is transparent — the CA trust persists as long as the same CA signs the new certificate.
    • Ideal for production deployments with a well-known CA.

3.2 x509_san_hash

client_id = "x509_san_hash:<base64url(SHA-256(DER-encoded leaf certificate))>"
  • Trust mechanism — certificate pinning. The wallet computes SHA-256 of the DER-encoded leaf certificate in x5c[0] and checks it matches the hash in client_id. No CA lookup is performed for identity binding.
  • Identity binding. The client_id is the fingerprint. Possession of the matching private key proves identity.
  • Implications:
    • No CA certificate needs to be in ReaderTrustStore for the hash check itself (the chain must still be validated per Section 5).
    • No hostname matching is required.
    • The client_id must be recomputed on every certificate rotation.
    • Ideal for local/dev environments with self-signed certificates.

3.3 Side-by-side comparison

Propertyx509_san_dnsx509_san_hash
Trust rootCA in ReaderTrustStoreSHA-256 fingerprint of leaf cert
client_id contenthostname (matches DNS SAN)base64url(SHA-256(DER leaf))
Certificate rotationTransparentRequires new client_id
Pre-bundled CA requiredYesNo (still needed for chain validation — see §5)
Hostname verificationYes (DNS SAN must match)No
Works with self-signed / private-CA certsLimitedYes
Good for productionYesLess convenient
Good for local dev / local networkLimitedYes

Practical rule: use x509_san_dns in production with a well-known CA, and x509_san_hash in development / local-network environments.


4. X.509 Certificate Profile

The leaf certificate (x5c[0]) is validated by two independent layers:

  1. JAR/JWS layer — signature verification using the public key in the leaf cert.
  2. ISO 18013-5 ProfileValidation layer — invoked through ReaderTrustStore. This is the source of most Untrusted x5c errors.

4.1 Mandatory Extensions

ExtensionRequirement
Subject Key Identifier (SKI)Must be present
Authority Key Identifier (AKI)Must be present and must reference the issuer’s SKI
Common Name (CN)Must be present in the Subject (used as the displayed verifier name in the UI)
Key UsageMust include digitalSignature
Extended Key UsageMust include OID 1.0.18013.5.1.6 (mdlReaderAuth)

4.2 Prohibited Critical Extensions

The following extensions must not be marked critical:

  • policyMappings
  • nameConstraints
  • policyConstraints
  • inhibitAnyPolicy
  • freshestCRL

4.3 Signature Algorithm

The certificate must be signed with one of:

  • ecdsa-with-SHA256
  • ecdsa-with-SHA384
  • ecdsa-with-SHA512

RSA signature algorithms are not supported.

4.4 Validity Period

The validity period of the leaf certificate must not exceed 1 187 days (~3.25 years).

4.5 Chain Requirements

  • The x5c array must contain the chain leaf first.
  • Including intermediate certificates is strongly recommended.
  • The root CA must be resolvable either from x5c itself or from ReaderTrustStore. In practice, include the root in x5c.

4.6 Summary OpenSSL Extensions Block

[ reader_leaf ]
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always
keyUsage               = critical, digitalSignature
extendedKeyUsage       = 1.0.18013.5.1.6   # mdlReaderAuth — MANDATORY
basicConstraints       = CA:FALSE
# validity ≤ 1187 days; signature algorithm must be ECDSA

⚠️ The mdlReaderAuth OID (1.0.18013.5.1.6) is the most common omission causing InvalidJarJwt(cause=Untrusted x5c). See eudi-lib-android-wallet-core#247.


5. Trust Anchor Configuration in the Wallet

ReaderTrustStore is configured in WalletCoreConfigImpl:

configureReaderTrustStore(
    context,
    R.raw.pidissuerca02_cz,
    R.raw.pidissuerca02_ee,
    R.raw.pidissuerca02_eu,
    R.raw.pidissuerca02_lu,
    R.raw.pidissuerca02_nl,
    R.raw.pidissuerca02_pt,
    R.raw.pidissuerca02_ut,
    R.raw.dc4eu,
    R.raw.r45_staging
)

Key facts:

  • Only the root CA certificate needs to be in ReaderTrustStore; intermediates may be in x5c.
  • ReaderTrustStore is shared between the proximity BLE path and the OpenID4VP remote path. Because the proximity path enforces the ISO 18013-5 profile (including mdlReaderAuth OID), this OID is also enforced for remote OpenID4VP in current releases.
  • ReaderTrustStore is initialised once at startup. A rebuild is required to add a new PEM file.
  • The wallet’s Ktor HttpClient uses the Android system CA trust store for TLS — this is a separate mechanism from ReaderTrustStore. A certificate valid for ReaderTrustStore does NOT automatically work for TLS (and vice versa).

6. Same-Device Flow

6.1 Overview

In the same-device flow the RP website and the wallet run on the same Android device. The RP triggers the wallet via a deep link (Android Intent URI) using one of the registered URI schemes. The wallet processes the request, collects user consent, and POSTs the VP Token directly to the RP’s response_uri. If the RP returns a redirect_uri in its response body, the wallet navigates to it after the presentation, returning control to the RP’s app or browser tab.

6.2 Sequence Diagram

sequenceDiagram
    participant Browser as RP Web Page (Same Device)
    participant OS as Android OS / Intent System
    participant Wallet as EUDI Wallet App
    participant RPServer as RP Backend Server

    Browser->>RPServer: POST /authorize (initiate presentation)
    RPServer-->>Browser: { request_uri, deep_link_uri }

    Browser->>OS: Trigger deep link<br/>haip-vp://?client_id=rp.example.com<br/>&client_id_scheme=x509_san_dns<br/>&request_uri=https://rp.example.com/req/abc

    OS->>Wallet: Launch wallet with deep link URI

    Wallet->>RPServer: GET https://rp.example.com/req/abc
    RPServer-->>Wallet: Signed Request Object JWT (JAR)

    Wallet->>Wallet: Validate JWT signature (ES256)<br/>Verify x5c chain vs ReaderTrustStore<br/>Check client_id == cert DNS SAN<br/>Enforce ProfileValidation (mdlReaderAuth OID)

    Wallet->>Wallet: Show consent screen<br/>(verifier name from CN, trusted badge if isVerified)
    Wallet->>Wallet: User approves + biometric confirmation

    Wallet->>RPServer: POST https://rp.example.com/response<br/>{ vp_token, presentation_submission, state }
    RPServer-->>Wallet: 200 OK { "redirect_uri": "haip-vp://callback?response_code=xyz" }

    Wallet->>OS: Open redirect_uri (Intent)
    OS->>Browser: Return to RP web page / app

6.3 URI Scheme Requirements

The wallet registers Android intent filters for the following URI schemes. The RP MUST use one of them to trigger the wallet:

URI SchemePurpose
haip-vp://HAIP-specific OpenID4VP (recommended for HAIP-compliant RPs)
openid4vp://Standard OpenID4VP scheme
eudi-openid4vp://EUDI-specific OpenID4VP scheme
mdoc-openid4vp://mDoc-specific OpenID4VP scheme

The host component is treated as a wildcard (*) — the wallet accepts any host for these schemes.

Example deep-link URI:

haip-vp://?client_id=rp.example.com
          &client_id_scheme=x509_san_dns
          &request_uri=https%3A%2F%2Frp.example.com%2Frequest%2Fabc123

For scalability, use request_uri by reference — embedding the full Request Object JWT in the URI bloats it.

6.4 Authorization Request Parameters

ParameterRequirement
client_idREQUIRED. For x509_san_dns: exact DNS hostname matching the SAN of the leaf cert in x5c. For x509_san_hash: base64url(SHA-256(DER(leaf_cert))).
client_id_schemeREQUIRED. MUST be x509_san_dns or x509_san_hash.
request_uriSTRONGLY RECOMMENDED. URL from which the wallet fetches the signed Request Object. MUST use https://.
requestAlternative to request_uri. JWT by value. Same signing requirements.
response_typeREQUIRED. MUST be vp_token.
response_modeREQUIRED. MUST be direct_post or direct_post.jwt.
response_uriREQUIRED when response_mode is direct_post. HTTPS endpoint for the VP Token POST.
nonceREQUIRED. Cryptographically random, single-use.
stateRECOMMENDED. Opaque value for session correlation.
presentation_definitionREQUIRED (unless using dcql_query).
dcql_queryREQUIRED (alternative to presentation_definition).

⚠️ HAIP requirement: The request_uri endpoint MUST return a signed JAR. Unsigned JSON is rejected.

6.5 Request Object Signing Requirements

PropertyRequirement
alg (JOSE header)MUST be ES256 (ECDSA P-256). All other algorithms are rejected.
x5c (JOSE header)REQUIRED. Array: [leaf_DER_base64, intermediate_DER_base64?, root_DER_base64?]
kid (JOSE header)OPTIONAL. If present, must identify the leaf certificate.
iss (JWT claim)MUST equal client_id.
aud (JWT claim)SHOULD be the wallet identifier or omitted.
exp (JWT claim)REQUIRED. Request Object must have an expiry time.
iat (JWT claim)REQUIRED. Issuance time.
nonce (JWT claim)REQUIRED. Same value as the nonce query parameter.

6.6 TLS / Transport Security Requirements

EndpointTLS Requirement
request_uri endpointValid TLS certificate from a publicly-trusted CA (Android system store). TLS 1.2 min; TLS 1.3 recommended.
response_uri endpointValid TLS certificate from a publicly-trusted CA.
Any server the wallet connects toNo self-signed certificates; no private/internal CAs (unless wallet is reconfigured).

The wallet’s Ktor HttpClient uses the unmodified Android TLS stack (NetworkModule.kt). Connections to servers with self-signed or privately-rooted TLS certificates fail at the TLS layer. The wiki (how_to_build.md) documents a trust-all workaround for development using ClientIdScheme.Preregistered.

6.7 Credential / Presentation Definition Requirements

MSO mDOC (ISO 18013-5)

Use presentation_definition with format.mso_mdoc or a DCQL query.

DocTypeDescription
eu.europa.ec.eudi.pid.1EUDI Person Identification Data (PID)
org.iso.18013.5.1.mDLMobile Driving Licence
org.iso.23220.2.photoid.1Photo ID
org.iso.23220.photoID.1Photo ID (alternative)
eu.europa.ec.eudi.tax.1Tax document
eu.europa.ec.eudi.iban.1IBAN
eu.europa.ec.eudi.hiid.1Health Insurance ID
eu.europa.ec.eudi.ehic.1European Health Insurance Card
eu.europa.ec.eudi.pda1.1Portable Document A1
eu.europa.ec.eudi.por.1Power of Representation
eu.europa.ec.eudi.msisdn.1MSISDN

SD-JWT VC

Use presentation_definition with format.vc+sd-jwt or a DCQL query.

VCTDescription
urn:eudi:pid:1EUDI PID – SD-JWT VC format
urn:eu.europa.ec.eudi:tax:1Tax document
urn:eu.europa.ec.eudi:iban:1IBAN
urn:eu.europa.ec.eudi:hiid:1Health Insurance ID
urn:eu.europa.ec.eudi:ehic:1European Health Insurance Card
urn:eu.europa.ec.eudi:pda1:1Portable Document A1
urn:eu.europa.ec.eudi:por:1Power of Representation
urn:eu.europa.ec.eudi:msisdn:1MSISDN
urn:eu.europa.ec.eudi:pseudonym_age_over_18:1Age-over-18 pseudonym

6.8 Response Endpoint Requirements

The wallet POSTs the VP Token as application/x-www-form-urlencoded to response_uri.

  • MUST be an https:// URL.
  • MUST accept POST bodies containing:
    • vp_token — VP Token (mDoc DeviceResponse as base64url CBOR, or SD-JWT VC string).
    • presentation_submission — PresentationSubmission JSON object.
    • state — if provided in the original request, echoed back.
  • MUST respond with HTTP 200 OK.
  • MAY include a JSON body with redirect_uri for same-device return:
    { "redirect_uri": "haip-vp://callback?response_code=abc" }
    
  • MUST NOT use 301/302 redirects.

6.9 Redirect URI Requirements

After posting the VP Token, the wallet follows the redirect_uri returned by the RP:

  • MUST be a URI the Android OS can route (typically https:// App Link or custom URI scheme).
  • MUST NOT be a javascript: URI.
  • The wallet opens the URI via Android Intent; unresolvable URIs are silently absorbed.
  • The redirect_uri and verifier name (from leaf cert CN) are shown on the success screen.

6.10 Verifier Trust Indication

The wallet displays a verified badge when the RP’s certificate chain validates:

val isTrusted = requestedDocuments.requestedDocuments
    .firstOrNull()?.readerAuth?.isVerified == true
verifierIsTrusted = isTrusted

The verifier name comes from readerAuth.readerCommonName (the CN of the leaf certificate). An unverifiable RP shows an unverified warning; the user can still consent.


7. Cross-Device OpenID4VP Flow

7.1 Overview

The RP is on a different device (desktop browser, web kiosk) while the wallet is on the user’s phone. The RP renders a QR code. The user scans it with the wallet. The wallet fetches the Request Object over HTTPS, collects consent, and POSTs the VP Token to response_uri. There is no redirect_uri in the cross-device variant.

7.2 Sequence Diagram

sequenceDiagram
    participant Desktop as RP Desktop Browser
    participant RPServer as RP Backend Server
    participant Wallet as EUDI Wallet (Android)

    Desktop->>RPServer: POST /authorize (initiate presentation)
    RPServer-->>Desktop: { request_uri, qr_content }
    Desktop->>Desktop: Render QR Code encoding Authorization Request URI

    Note over Wallet: User scans QR code with wallet

    Wallet->>Wallet: Parse scanned URI<br/>e.g. haip-vp://?client_id=rp.example.com<br/>&client_id_scheme=x509_san_dns<br/>&request_uri=https://rp.example.com/req/xyz

    Wallet->>RPServer: GET https://rp.example.com/req/xyz
    RPServer-->>Wallet: Signed Request Object JWT (JAR)

    Wallet->>Wallet: Validate JWT (ES256)<br/>Verify x5c chain vs ReaderTrustStore<br/>Check client_id == cert DNS SAN<br/>Enforce mdlReaderAuth OID
    Wallet->>Wallet: Show consent screen
    Wallet->>Wallet: User approves + biometric confirmation

    Wallet->>RPServer: POST https://rp.example.com/response<br/>{ vp_token, presentation_submission, state }
    RPServer-->>Wallet: 200 OK (no redirect_uri in cross-device)

    RPServer->>Desktop: WebSocket / SSE / polling: presentation verified
    Desktop->>Desktop: Show success / grant access

7.3 QR Code Requirements

  • The QR code MUST encode a URI using a wallet-registered scheme: haip-vp, openid4vp, eudi-openid4vp, or mdoc-openid4vp.
  • Use request_uri by reference (not inline JWT — too large for reliable QR scanning).
  • Minimum QR code size: 200 × 200 px; higher recommended.
  • Regenerate QR code per session (new nonce and request_uri) to prevent replay.

Example QR content:

haip-vp://?client_id=rp.example.com
          &client_id_scheme=x509_san_dns
          &request_uri=https%3A%2F%2Frp.example.com%2Frequest%2Fxyz789
          &response_type=vp_token
          &response_mode=direct_post

7.4 request_uri Endpoint Requirements

RequirementDetail
ProtocolMUST be https:// with a publicly-trusted TLS certificate
Content-TypeMUST be application/oauth-authz-req+jwt or application/jwt
BodyMUST be a signed Request Object JWT. Plain JSON objects are rejected.
AuthenticationMUST NOT require authentication from the wallet (unauthenticated GET)
Single-useSHOULD invalidate the URI after first fetch
TTLSHOULD be ≤ 5 minutes on the JWT exp claim

7.5 Response Endpoint

Identical to the same-device response endpoint (§6.8) with one difference: do not return a redirect_uri in the cross-device flow. Notify the desktop browser via WebSocket, Server-Sent Events (SSE), or polling.


8. Cross-Device Proximity Flow (ISO 18013-5 BLE/NFC)

8.1 Overview

The proximity flow implements ISO/IEC 18013-5 mDOC presentation over BLE or NFC. Key difference from the OpenID4VP flows: the wallet generates the QR code — not the RP. The RP reader scans the wallet’s QR, connects over BLE, and exchanges data locally.

  • Wallet = mDOC holder device (BLE peripheral / NFC card emulator).
  • RP reader = mDOC reader device (BLE central / NFC reader).

8.2 Sequence Diagram

sequenceDiagram
    participant Wallet as EUDI Wallet (Holder Device)
    participant Reader as RP Reader (Verifier Device)

    Wallet->>Wallet: User opens Proximity screen
    Wallet->>Wallet: EudiWallet.startProximityPresentation()
    Wallet->>Wallet: Generate DeviceEngagement CBOR (ISO 18013-5 §8)
    Wallet->>Wallet: Render QR Code (DeviceEngagement CBOR)

    Reader->>Wallet: Scan Wallet QR code (or NFC tap)
    Reader->>Reader: Parse DeviceEngagement structure
    Reader->>Wallet: BLE connection (mDOC Session Establishment)
    Wallet->>Wallet: TransferEvent.Connected

    Reader->>Wallet: Send mdoc Request<br/>(ReaderAuthentication + requested namespaces)
    Wallet->>Wallet: TransferEvent.RequestReceived<br/>Verify ReaderAuth cert chain vs IACA trust store<br/>Extract readerCommonName → verifierIsTrusted

    Wallet->>Wallet: Show consent screen
    Wallet->>Wallet: User approves + biometric confirmation

    Wallet->>Wallet: Generate DeviceResponse CBOR<br/>(DeviceAuthentication signed with device key)
    Wallet->>Reader: Send mdoc Response over BLE

    Reader->>Reader: Verify DeviceResponse (IssuerSigned + DeviceSigned)
    Reader->>Reader: Extract attributes, finalise verification

    Wallet->>Wallet: TransferEvent.ResponseSent → Success screen

8.3 Device Engagement QR Code (Generated by the Wallet)

The wallet generates the QR code encoding the DeviceEngagement CBOR per ISO 18013-5 §8.3.1.

The RP reader MUST:

  • Implement ISO 18013-5 QR-code engagement parsing.
  • Parse CBOR DeviceEngagement to extract BLE connection parameters.
  • Initiate BLE central role and connect to the wallet.

The wallet also supports NFC engagement via NfcEngagementService (HCE).

8.4 Reader Authentication Requirements

The RP reader MUST include a ReaderAuthentication structure in the mdoc request (ISO 18013-5 §9.1.4):

ReaderAuthentication = [
  "ReaderAuthentication",
  SessionTranscript,
  ItemsRequestBytes
]

The wallet evaluates readerAuth.isVerified:

  • The reader certificate (x5chain) MUST chain to an IACA root in ReaderTrustStore.
  • The ReaderAuthentication MUST be signed with ES256 (ECDSA P-256 + SHA-256).
  • The displayed verifier name comes from the CN of the reader certificate’s Subject.

8.5 IACA Trust Store (Proximity — Bundled PEM Files)

FileDescription
pidissuerca02_cz.pemPID Issuer CA – Czech Republic
pidissuerca02_ee.pemPID Issuer CA – Estonia
pidissuerca02_eu.pemPID Issuer CA – EU (EUDIW)
pidissuerca02_lu.pemPID Issuer CA – Luxembourg
pidissuerca02_nl.pemPID Issuer CA – Netherlands
pidissuerca02_pt.pemPID Issuer CA – Portugal
pidissuerca02_ut.pemPID Issuer CA – UT (test)
dc4eu.pemDC4EU root CA
r45_staging.pemR45 staging root CA

If the reader certificate does not chain to one of these roots, verifierIsTrusted = false. The wallet still presents the request but the UI shows an unverified warning.

8.6 BLE and NFC Transport Requirements

FeatureRequirement
BLEREQUIRED. Reader MUST support BLE central role.
BLE versionBLE 4.2+; BLE 5.0 recommended.
NFCOPTIONAL. ISO 14443 Type 4 compliant.
BLE MTUSHOULD negotiate ≥ 512 bytes.
BLE GATTMUST implement ISO 18013-5 GATT service and characteristic UUIDs.

8.7 mdoc Request Structure

SessionData {
  DeviceRequest {
    version: "1.0",
    docRequests: [{
      itemsRequest: {
        docType: "eu.europa.ec.eudi.pid.1",
        nameSpaces: {
          "eu.europa.ec.eudi.pid.1": {
            "family_name": false,
            "given_name":  false,
            "birth_date":  false
          }
        }
      },
      readerAuth: {
        x5chain: [ <DER-encoded reader cert> ],
        signature: <ES256 over ReaderAuthentication CBOR>
      }
    }]
  }
}

9. Credential Format Requirements

FormatEncodingSigning AlgorithmWallet Support
MSO mDOC (ISO 18013-5)CBORECDSA P-256 (ES256)✅ Full
SD-JWT VCJSON + JWSECDSA P-256 (ES256)✅ Full

Only ES256 is supported. Requests specifying RS256, PS256, or EdDSA in vp_formats will not match any credential.

Credential policy: PID documents are OneTimeUse with 10–60 credentials batched per flavour. Each presentation consumes one credential. The RP MUST NOT expect the same key/certificate in multiple presentations.


10. Credential Revocation Behaviour

The wallet checks document status every 15 minutes (configurable via WalletCoreConfig.revocationInterval).

StatusBehaviour
ValidDocument available for presentation.
Invalid (revoked)Excluded from consent screen; cannot be presented.
SuspendedExcluded from consent screen.

If all matching documents for the requested credential type are revoked, the wallet presents the user with a NoData state. The RP MUST handle VP Token responses where the requested credential is absent.


11. Summary of Hard Requirements

#RequirementFlow
R1RP MUST use URI scheme haip-vp, openid4vp, eudi-openid4vp, or mdoc-openid4vpSame-device, Cross-device OID4VP
R2client_id_scheme MUST be x509_san_dns or x509_san_hashSame-device, Cross-device OID4VP
R3Request Object MUST be a signed JWT with alg: ES256Same-device, Cross-device OID4VP
R4x5c JOSE header MUST contain the full certificate chain (leaf first)Same-device, Cross-device OID4VP
R5For x509_san_dns: client_id MUST exactly match the DNS SAN of the leaf certificateSame-device, Cross-device OID4VP
R6Leaf certificate MUST contain Extended Key Usage mdlReaderAuth OID 1.0.18013.5.1.6All OID4VP flows
R7Leaf certificate MUST have valid SKI and AKI (AKI must point to issuer’s SKI)All OID4VP flows
R8Leaf certificate MUST include Key Usage digitalSignatureAll OID4VP flows
R9Leaf certificate validity period MUST NOT exceed 1 187 daysAll OID4VP flows
R10All RP HTTPS endpoints MUST have publicly-trusted TLS certificatesAll OID4VP flows
R11response_mode MUST be direct_post or direct_post.jwtSame-device, Cross-device OID4VP
R12response_uri MUST be an HTTPS endpoint accepting application/x-www-form-urlencoded POSTSame-device, Cross-device OID4VP
R13Cross-device QR code MUST encode an OpenID4VP URI using a wallet-registered schemeCross-device OID4VP
R14Reader certificate MUST chain to an IACA root in the wallet’s trust store for trusted statusProximity BLE
R15Reader MUST sign ReaderAuthentication with ES256Proximity BLE
R16Reader MUST implement ISO 18013-5 BLE GATT profileProximity BLE
R17Credential format MUST be MSO mDOC (ES256) or SD-JWT VC (ES256)All flows
R18nonce MUST be present and single-useAll OID4VP flows
R19Self-signed TLS or request-signing certificates are NOT accepted in productionAll flows

12. Common Error Messages and Their Causes

ErrorLikely CauseFix
InvalidJarJwt(cause=Untrusted x5c)Missing mdlReaderAuth OID, missing root CA in ReaderTrustStore, or missing SKI/AKIAdd OID 1.0.18013.5.1.6 to leaf cert; add CA to trust store
CertPathValidatorException: Trust anchor not foundRoot CA not in ReaderTrustStore and not in x5cInclude root in x5c and/or ReaderTrustStore
Field 'vp_formats' is required … but it was missingOpenID4VP 1.0 client_metadata incompleteAdd vp_formats to client_metadata
InvalidJarJwt(cause=…) with x509_san_dnsDNS SAN in leaf cert does not match client_id hostnameEnsure leaf cert’s DNS SAN equals client_id
Validity period check failureLeaf cert validity > 1 187 daysRe-issue with -days 1187 or shorter
Signature algorithm mismatchCert signed with RSAUse ECDSA P-256/P-384/P-521
TLS handshake failurePrivate/self-signed TLS cert on RP serverUse publicly-trusted TLS cert; or trust-all HttpClient for dev

13. Known Limitations and GitHub Issues

IssueDescriptionStatus
eudi-app-android-wallet-ui#500TrustStore update with new PEM does not make verifier request trustedClosed – resolved by correct cert profile
eudi-lib-android-wallet-core#230configureReaderTrustStore has no effect / Untrusted x5cClosed – mdlReaderAuth OID + valid AKI/SKI required
eudi-lib-android-wallet-core#247ReaderTrustStore shared between proximity and remote VP – mdlReaderAuth OID enforced for all flowsOpen – separate trust stores on the roadmap
eudi-app-android-wallet-ui#439Wallet incompatible with verifier 0.6.0 (OpenID4VP draft vs 1.0)Closed – wallet updated to OpenID4VP 1.0

14. Glossary

TermDefinition
HAIPHigh Assurance Interoperability Profile. A profile of OpenID4VP mandating X.509 certificate-based RP authentication, direct_post response mode, and ES256 signing. Defined by the OpenID Foundation and referenced by the EUDI ARF.
OpenID4VPOpenID for Verifiable Presentations. A protocol built on OAuth 2.0 allowing a Verifier (RP) to request credential presentation from a Wallet.
JOSEJSON Object Signing and Encryption. Umbrella term for RFC 7515 (JWS), RFC 7516 (JWE), RFC 7517 (JWK), RFC 7518 (JWA), RFC 7519 (JWT).
JWSJSON Web Signature (RFC 7515). Defines how to represent signed content, including Compact Serialization (header.payload.signature).
JWTJSON Web Token (RFC 7519). A signed (JWS) or encrypted (JWE) token carrying JSON claims.
JARJWT-Secured Authorization Request (RFC 9101). An Authorization Request whose parameters are embedded in a signed JWT.
x5cJOSE header parameter (RFC 7515 §4.1.6) containing an X.509 certificate chain (base64-DER, leaf first) used to verify the JWT’s signature.
client_idThe identifier of the RP in an OpenID4VP Authorization Request. In HAIP its value is constrained by client_id_scheme.
client_id_schemeDefines how client_id is interpreted. This wallet supports x509_san_dns and x509_san_hash.
x509_san_dnsClient ID scheme where the RP’s identity is a DNS name matching the Subject Alternative Name of the leaf cert in x5c.
x509_san_hashClient ID scheme where client_id is base64url(SHA-256(DER(leaf_cert))). Certificate pinning without CA lookup.
Request ObjectA signed JWT (JAR) containing all OpenID4VP Authorization Request parameters. Served from request_uri.
request_uriURL from which the wallet fetches the signed Request Object. Keeps the Authorization Request URI (and QR code) compact.
response_uriHTTPS endpoint where the wallet POSTs the VP Token. Used with response_mode: direct_post.
response_modeHow the wallet returns the VP Token. direct_post = wallet POSTs to response_uri. direct_post.jwt = encrypted POST.
VP TokenVerifiable Presentation Token. The wallet’s response containing disclosed credential attributes signed by the holder’s device key.
Presentation DefinitionJSON structure (DIF Presentation Exchange) specifying which credential types and attributes the RP requests.
DCQLDigital Credential Query Language. Alternative to Presentation Definition for specifying requested credentials. Part of OpenID4VP.
MSO mDOCMobile Security Object Mobile Document. Credential format defined by ISO 18013-5. CBOR-encoded.
SD-JWT VCSelective Disclosure JSON Web Token Verifiable Credential. JWT-based credential with selective disclosure.
ES256ECDSA with P-256 and SHA-256. The only signing algorithm supported by this wallet for credential formats and request signing.
IACAIssuer Authority Certificate Authority. Root CA for the mDOC ecosystem. Proximity reader certs must chain to a trusted IACA.
ReaderAuthenticationISO 18013-5 structure signed by the RP reader to prove its identity. Contains session transcript and request bytes.
DeviceEngagementISO 18013-5 CBOR structure generated by the wallet encoding BLE/NFC connection parameters. Displayed as a QR code for the RP to scan in the proximity flow.
Device Engagement QRQR code generated by the wallet (not the RP) in the proximity flow. The RP reader scans it to initiate BLE.
ReaderTrustStoreWallet-side store of trusted CA certificates. Configured via configureReaderTrustStore(…). Shared between proximity and remote OpenID4VP paths.
ProfileValidationISO 18013-5 certificate validation logic enforced by ReaderTrustStore. Requires mdlReaderAuth OID, valid SKI/AKI, etc.
mdlReaderAuth OIDOID 1.0.18013.5.1.6. Extended Key Usage value required by ISO 18013-5 for reader certificates. Also enforced for remote OpenID4VP (see §4).
SANSubject Alternative Name. X.509v3 extension containing additional identities (DNS, IP, email) for the certificate subject.
DPoPDemonstration of Proof of Possession. Mechanism binding access tokens to a client key pair. Used in OpenID4VCI issuance (DPopConfig.Default).
PARPushed Authorization Request (RFC 9126). Client pushes Authorization Request to the server before redirecting the user. Supported in issuance (ParUsage.IF_SUPPORTED).
Wallet AttestationSigned JWT from the Wallet Provider attesting to the wallet instance’s authenticity. Required for OpenID4VCI issuance.
NonceCryptographically random, single-use value in an Authorization Request. The wallet binds the VP Token to the nonce, preventing replay.
verifierIsTrustedBoolean set by the wallet when the RP’s cert chain validates against ReaderTrustStore. Shown as a verified badge in the UI.
same-device flowOpenID4VP flow where the RP web page and wallet run on the same mobile device. RP triggers the wallet via deep link / Android Intent.
cross-device flowFlow where the RP (website or reader) is on a different device from the wallet. For OpenID4VP: RP shows a QR; wallet scans it. For proximity: wallet shows a QR; RP reader scans it.
deep linkAndroid Intent URI that launches a specific app screen. The wallet registers haip-vp://, openid4vp://, eudi-openid4vp://, mdoc-openid4vp://.
BLEBluetooth Low Energy. Wireless transport for ISO 18013-5 proximity presentation (wallet = peripheral, RP reader = central).
HCEHost Card Emulation. Android feature letting the device emulate an NFC card. Used by NfcEngagementService for proximity NFC engagement.
redirect_uriURI returned by the RP server (in the VP Token POST response body) to which the wallet navigates after a successful same-device presentation.
PIDPerson Identification Data. Primary eID credential in the EUDI ecosystem. DocType eu.europa.ec.eudi.pid.1 (mDOC); VCT urn:eudi:pid:1 (SD-JWT VC).

15. Reference Specifications

SpecificationURL
OpenID4VP 1.0https://openid.net/specs/openid-4-verifiable-presentations-1_0.html
HAIP profilehttps://openid.net/specs/openid4vc-high-assurance-interoperability-profile-1_0.html
ISO/IEC 18013-5:2021 (mDL/mDOC)https://www.iso.org/standard/69084.html
ISO/IEC 18013-7 (Online mDOC)https://www.iso.org/standard/82772.html
RFC 7515 – JWShttps://www.rfc-editor.org/rfc/rfc7515
RFC 7517 – JWKhttps://www.rfc-editor.org/rfc/rfc7517
RFC 7518 – JWAhttps://www.rfc-editor.org/rfc/rfc7518
RFC 7519 – JWThttps://www.rfc-editor.org/rfc/rfc7519
RFC 9101 – JARhttps://www.rfc-editor.org/rfc/rfc9101
RFC 9126 – PARhttps://www.rfc-editor.org/rfc/rfc9126
DIF Presentation Exchange 2.0https://identity.foundation/presentation-exchange/spec/v2.0.0/
SD-JWT VChttps://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-08.txt
EUDI ARFhttps://eu-digital-identity-wallet.github.io/eudi-doc-architecture-and-reference-framework/
eudi-lib-android-wallet-corehttps://github.com/eu-digital-identity-wallet/eudi-lib-android-wallet-core

Last updated: April 2026

Summary of Protocols and Formats

This page summarises the protocols, standards, credential formats, and implementation profiles used in the EU Digital Identity ecosystem. It serves as a compact reference; detailed user journeys and flow diagrams are in the User Journey.


Protocols — ISO/IEC 18013-7 Annexes

ISO/IEC 18013-7 defines how mobile driving licences (mDLs) and generic mDocs are presented over the internet (remote presentation), extending the proximity-based protocols of ISO/IEC 18013-5. Three distinct annexes define different transport and invocation mechanisms.

Annex A: REST API (Device Retrieval)

AspectDetail
InvocationHTTP POST directly to a wallet endpoint or proxy server
Request payloadCBOR-encoded DeviceRequest (ISO 18013-5)
Response payloadCBOR-encoded DeviceResponse
EncryptionNetwork-layer session encryption (no standardised session protocol)
Trust modelNo global trust list; verifier authenticates via TLS
Developer impactRequires CBOR parsing, COSE cryptography, and session key establishment at the network layer
Wallet supportNone of the major EUDI wallets (France Identité, EUDI Wallet DE, IT Wallet, Spanish EUDIW) implement Annex A

Annex B: OpenID4VP (OpenID for Verifiable Presentations)

AspectDetail
InvocationUniversal / deep links (openid4vp://), request_uri fetch, HTTP direct_post
Request payloadDCQL query (JSON); legacy PEX supported as fallback
Response payloadvp_token containing mDoc or SD-JWT, optionally JWE-encrypted (direct_post.jwt)
Request signingJAR (JWT Secured Authorization Request, RFC 9101) — required by HAIP
Trust modelVerifier identity verified via X.509 certificates or redirect URI matching
Developer impactRequires JWT/JWE handling, DCQL evaluation, and OpenID4VP state management
Wallet supportAll major EUDI wallets

Annex C: W3C Digital Credentials API (DC API)

Annex C defines two sub-protocols that both run over the W3C Digital Credentials API (navigator.credentials.get({ digital: ... })). The browser/OS handles wallet discovery, invocation, and user consent via a credential chooser, then delivers the request to the user-selected wallet.

Sub-protocol A: Raw ISO mDoc (org-iso-mdoc)

The classic ISO 18013-7 Annex C payload: a CBOR-structured request with HPKE encryption.

AspectDetail
Protocol identifierorg-iso-mdoc
InvocationBrowser API: navigator.credentials.get({ digital: { requests: [...] } })
Request payload["dcapi", { nonce, recipientPublicKey }] (CBOR) + CBOR DeviceRequest — both base64url-encoded as opaque strings in the data field
Response payloadHPKE-encrypted mDoc inside the ["dcapi", { enc, cipherText }] CBOR wrapper — returned as an opaque string in the data field
EncryptionHPKE (X25519 + HKDF-SHA256 + AES-128-GCM)
Wallet supportRare — most wallets do not implement a separate CBOR/HPKE code path just for Annex C

Sub-protocol B: OpenID4VP over DC API (openid4vp-v1-*)

This is what wallets actually implement. The verifier sends a standard OpenID4VP Authorization Request (with dcql_query, nonce, client_metadata), and the wallet returns a VP Token — all tunnelled through the DC API instead of via openid4vp:// deep links or direct_post.

AspectDetail
Protocol identifiersopenid4vp-v1-unsigned, openid4vp-v1-signed, openid4vp-v1-multisigned (see OpenID4VP §A.1)
InvocationBrowser API: navigator.credentials.get({ digital: { requests: [...] } })
Request payloadStandard OpenID4VP Authorization Request parameters as JSON in the data field: response_type, response_mode, nonce, dcql_query, client_metadata, optionally client_id and request for signed requests
Response payloadOpenID4VP Authorization Response containing the vp_token, optionally JWE-encrypted (response_mode: "dc_api.jwt"): the encrypted JWT is returned as a JSON string in the data field
Response modesdc_api (unencrypted JSON, not recommended) or dc_api.jwt (JWE-encrypted Authorization Response — the wallet encrypts the vp_token using the verifier’s public key from client_metadata.jwks, per OpenID4VP §8.3)
Wallet supportTheoretical — not yet confirmed in production. The EUDI Wallet Core library includes DC API handling code, but production wallets (France Identité, EUDI Wallet DE, IT Wallet, Spanish EUDIW) do not yet recognize the openid4vp protocol identifier inside the DC API handler. See the demo webapp page for details.

Summary comparison

Aspectorg-iso-mdocopenid4vp-v1-*
Payload formatCBOR (["dcapi", ...])JSON (OpenID4VP)
EncryptionHPKE (mandatory)JWE (dc_api.jwt, mandatory by modern profiles)
Request structureencryptionInfo + deviceRequest CBOR blobsdcql_query, nonce, client_metadata
Response structureCBOR-encoded HPKE ciphertextJWE-encrypted VP Token
Wallet implementationSeparate code path (CBOR + COSE + HPKE)Reuses OpenID4VP logic from Annex B
AdoptionLowNot yet demonstrated in production — documented as broken on Android 15+ in practice (see the demo webapp page)

In short: “Wraps OpenID4VP as dc_api.jwt” means the wallet receives a standard OpenID4VP Authorization Request through the DC API, processes it with its existing OpenID4VP handler, then encrypts the resulting VP Token as a JWE (using response_mode: dc_api.jwt) before returning it through the DC API.


OpenID4VP Profiles

EU Age Verification Profile (EU-AV Blueprint)

AspectDetail
PurposePrivacy-preserving, anonymous age checks (e.g. “Over 18” for online services)
Driven byDigital Services Act (DSA)
Primary methodISO/IEC 18013-7 Annex C (W3C DC API)
Fallback methodOpenID4VP Annex B (redirect_uri scheme, no JAR, direct_post)
Request signingNo JAR — deliberately omitted to prevent verifier tracking
Privacy modelDouble-blind: verifier gets binary yes/no without user identity; issuer cannot see where credential is used
Client ID schemeredirect_uri (fallback only)
Credential formatmso_mdoc (eu.europa.ec.av.1)
Wallet supportAV-compatible wallets (EUDI Wallet Referenz, AVI) via Annex B deep-link fallback. Annex C (W3C DC API) is the specified primary method, but no wallet has demonstrated working Annex C support in production as of mid-2026.

High Assurance Interoperability Profile (HAIP)

AspectDetail
PurposeHigh-security credential exchange for PID, mDL, and other sensitive credentials
Managed byOpenID Foundation
Request signingStrict JAR required — verifier MUST authenticate via EU Trust List X.509 certificates
Response encryptionJWE (direct_post.jwt) — mandatory
Client ID schemesx509_san_dns, x509_hash, x509_san_uri
Credential formatsmso_mdoc (mDL, PID) and dc+sd-jwt (PID)
Query languageDCQL (mandated by EUDIW per ARF)
Wallet supportAll major EUDI wallets (France Identité, EUDI Wallet DE, IT Wallet, Spanish EUDIW)

Credential Formats

mso_mdoc (ISO/IEC 18013-5)

AspectDetail
Data modelCBOR-encoded DeviceResponse containing one or more Document objects
Issuer signatureCOSE_Sign1 — the Mobile Security Object (MSO) over the issuer-signed data
Device signatureCOSE_Sign1DeviceSignature over DeviceAuthentication (includes SessionTranscript)
Selective disclosureMSO contains SHA-256 digests of each claim issuer-signed item; wallet discloses only requested items
Key bindingDeviceKey in MSO → DeviceSignature proves holder possession of corresponding private key
Used bymDL, PID, EU Age Verification, National ID, EHIC, and most other EUDI credentials
Namespacee.g. org.iso.18013.5.1 (mDL), eu.europa.ec.av.1 (Age Verification)

SD-JWT VC (Selective Disclosure JWT for Verifiable Credentials)

AspectDetail
StandardIETF SD-JWT VC
Data modelJWT-encoded verifiable credential with selectively-disclosable claims
Issuer signatureJWT signed with issuer’s private key (x5c certificate chain)
Holder bindingKB-JWT (Key Binding JWT) — proves holder possession of the bound key
Selective disclosure_sd digests in the issuer JWT; wallet reveals only requested salted digests
Key bindingcnf (confirmation) claim in issuer JWT → holder proves possession of corresponding private key via KB-JWT
Used byPID (HAIP profile), National ID, EHIC, and other EUDI credentials in the HAIP profile
vcte.g. urn:eu.europa.ec.eudi:pid:1 (PID), urn:eu.europa.ec.eudi:ehic:1 (EHIC)

Key Differences: mso_mdoc vs SD-JWT VC

Aspectmso_mdocSD-JWT VC
EncodingCBOR (binary)JSON / JWT (text)
Issuer signatureCOSE_Sign1 (CBOR)JWT (JSON)
Holder bindingDeviceSignature over SessionTranscriptKB-JWT
Selective disclosureMSO digest-based_sd digests
NamespaceISO namespace stringsvct (Verifiable Credential Type)
Session bindingOpenID4VP Handover (SessionTranscript)KB-JWT nonce + audience
Web-friendlinessRequires CBOR/COSE librariesWorks with standard JWT libraries

EUDI Wallet Implementation Matrix

Wallet support for ISO/IEC 18013-7 annexes across major European member states, based on observed behaviour in testing and public documentation (as of May 2026):

SchemeTransportPayloadTrustFranceGermanyItalySpain
ISO 18013-7 Annex AHTTP REST APICBOR DeviceRequest/ResponseNetwork-layer encryption❌ Not implemented❌ Not implemented❌ Not implemented❌ Not implemented
ISO 18013-7 Annex Bopenid4vp://, direct_postDCQL (JSON)JAR + TLS✅ Production, DCQL + JAR✅ Beta/Sandbox, DCQL + JAR✅ Production, DCQL + JAR✅ Pilot, DCQL + JAR
ISO 18013-7 Annex Cnavigator.credentials.get()Sub-protocol A: CBOR ["dcapi",...] + HPKE
Sub-protocol B: OpenID4VP JSON + JWE (dc_api.jwt)Web origins + App Links⚠️ Sub-protocol B: in development — EUDI Wallet Core added DC API support, but the openid4vp protocol identifier is not yet recognised by production wallets (see note below)
❌ Sub-protocol A: not implemented⚠️ Sub-protocol B: in development (same limitation)
❌ Sub-protocol A: not implemented⚠️ Sub-protocol B: in development (same limitation)
❌ Sub-protocol A: not implemented⚠️ Sub-protocol B: in development (same limitation)
❌ Sub-protocol A: not implemented

Note on Annex C: While the EUDI Wallet Core library includes DC API plumbing, production wallets reject the openid4vp protocol identifier with “Unsupported protocol” errors. The W3C DC API Annex C flow is therefore not functional with any current EUDI wallet. All wallets use Annex B (deep-link OpenID4VP) for production flows. See the demo webapp page for the full technical analysis. | OpenID4VP HAIP | OpenID4VP, direct_post.jwt | DCQL | Strict JAR + EU Trust List | ✅ DCQL + strict JAR | ✅ DCQL + strict JAR | ✅ DCQL + strict JAR | ✅ DCQL + strict JAR | | EUDIW EU-AV Blueprint | Priority: Annex C → Annex B | DCQL (minimal disclosure) | No JAR | ✅ Drops JAR for privacy | ✅ Drops JAR for privacy | ✅ Drops JAR for privacy | ✅ Core focus, drops JAR |

Note: This matrix reflects observed wallet behaviour from testing and public documentation, not formal compliance certifications. Wallet capabilities evolve rapidly — consult each member state’s latest wallet release notes for current status.


References

ISO/IEC 18013-7 — mDoc Online Presentation

This reference covers ISO/IEC 18013-7, the standard for presenting mobile driving licences (mDLs) and generic mDocs over the internet, and its relationship with the OpenID4VP profiles used in the EUDI ecosystem.


Overview

ISO/IEC 18013-7 extends the proximity-based protocols of ISO/IEC 18013-5 to remote presentation over the internet. It defines three annexes, each with a different transport and invocation mechanism:

AnnexNameTransportWallet Adoption
Annex AREST API (Device Retrieval)HTTP POST with raw CBOR❌ None of the major EUDI wallets
Annex BOpenID4VP (Online Presentation)openid4vp:// URIs, direct_post✅ All major EUDI wallets
Annex CW3C Digital Credentials APInavigator.credentials.get()✅ All major EUDI wallets

Terminology note: In the France Identité ecosystem and other national mDL deployments, “ISO 18013-7 (Annex B)” is used as an umbrella term for the entire online mDoc presentation flow. Annex C defines the ["dcapi", ...] CBOR wrapper that carries the DeviceRequest/DeviceResponse inside the browser API — it is the encoding layer, not a standalone profile.


Annex A: REST API (Device Retrieval)

Pure HTTP-based remote device retrieval: the verifier sends a CBOR-encoded DeviceRequest via HTTP POST to a wallet endpoint or proxy server, and receives a CBOR DeviceResponse in the HTTP response.

AspectDetail
InvocationHTTP POST directly to wallet endpoint or proxy
RequestCBOR DeviceRequest (ISO 18013-5)
ResponseCBOR DeviceResponse
EncryptionNetwork-layer session encryption (no standardised protocol)
TrustNo global trust list; verifier authenticates via TLS
Developer impactRequires CBOR parsing, COSE cryptography, and session key establishment at network layer
Wallet supportNone — France Identité, EUDI Wallet DE, IT Wallet, Spanish EUDIW do not implement Annex A

Implementation status in ewQwe: ❌ Not implemented. No wallet supports Annex A; the standard is not used by any major EUDI wallet.


Annex B: OpenID4VP (Online Presentation)

Standardises mDoc presentation over the OAuth 2.0 / OpenID Connect framework. The verifier acts as an OAuth 2.0 client, building an Authorization Request URI that the wallet processes.

Flow

sequenceDiagram
    participant RP as Relying Party
    participant Wallet as Wallet

    RP->>Wallet: openid4vp:// URI (QR / link)<br/>(or request_uri fetch)
    Note over Wallet: User selects credential,<br/>consents to release
    Wallet-->>RP: POST /direct_post (VP Token)<br/>(optionally JWE-encrypted)
    Note over RP: Validate signatures + nonce
    Note over RP: RP processes credential claims
AspectDetail
InvocationUniversal / deep links (openid4vp://), request_uri fetch, HTTP direct_post
RequestDCQL query (JSON); legacy PEX supported as fallback
Responsevp_token containing mDoc or SD-JWT, optionally JWE-encrypted (direct_post.jwt)
Request signingJAR (JWT Secured Authorization Request, RFC 9101)
Trust modelVerifier identity verified via X.509 certificates or redirect URI matching
Developer impactRequires JWT/JWE handling, DCQL evaluation, OpenID4VP state management
Wallet supportAll major EUDI wallets

Profiles Using Annex B

Two profiles use Annex B transport — see OpenID4VP Profiles below.


Annex C: W3C Digital Credentials API (DC API)

Moves the request mechanism into the client-side browser using navigator.credentials.get(). The browser and OS handle wallet invocation.

Flow

sequenceDiagram
    participant RP as Relying Party (Browser)
    participant Wallet as Wallet (OS-level)

    RP->>Wallet: navigator.credentials.get()<br/>{ digital: { requests: [{<br/>    protocol: "org-iso-mdoc",<br/>    data: { encryptionInfo,<br/>           deviceRequest }<br/>  }] } }
    Note over Wallet: User selects credential,<br/>consents to release
    Wallet-->>RP: Promise resolves with CBOR<br/>["dcapi", { enc, cipherText }]
    Note over RP: HPKE-decrypt DeviceResponse<br/>Verify signatures
    Note over RP: RP processes credential claims
AspectDetail
InvocationBrowser API: navigator.credentials.get({ digital: ... })
Request payload["dcapi", { nonce, recipientPublicKey }] + CBOR DeviceRequest
Response payloadHPKE-encrypted mDoc inside ["dcapi", { enc, cipherText }]
EncryptionHPKE (X25519 + HKDF-SHA256 + AES-128-GCM)
TransportBrowser/OS handles wallet invocation; BLE proximity checks + relay servers for cross-device
Trust modelTrust based on web origins and platform-verified App Links
Developer impactMinimal — browser handles wallet discovery and session binding
Wallet supportAll major EUDI wallets (wraps OpenID4VP request as dc_api.jwt)

Annex C Request Format

The request is two base64url-encoded CBOR blobs:

encryptionInfo — tells the wallet how to encrypt the response:

["dcapi", { nonce: bstr, recipientPublicKey: COSE_Key }]

deviceRequest — what to present, per ISO 18013-5:

{
  "version": "1.0",
  "docRequests": [{
    "docType": "eu.europa.ec.av.1",
    "itemsRequest": {
      "nameSpaces": {
        "eu.europa.ec.av.1": { "age_over_18": true }
      }
    }
  }]
}

Annex C Response Format

The wallet returns an HPKE-encrypted response:

["dcapi", { enc: bstr, cipherText: bstr }]

The RP HPKE-decrypts using its private key to obtain the CBOR DeviceResponse.


OpenID4VP Profiles

Two profiles use Annex B transport. For a detailed comparison see the Protocols & Formats Summary.

  • EU Age Verification (EU-AV) Blueprint — privacy-preserving age checks driven by the DSA. Uses Annex B as a fallback with redirect_uri scheme, no JAR, plain direct_post. The primary method is Annex C (DC API).
  • High Assurance Interoperability Profile (HAIP) — high-security credential exchange for PID and mDL. Requires strict JAR signing with X.509 certificates (x509_san_dns or x509_hash), and JWE-encrypted responses (direct_post.jwt).

References

DCQL Age Verification Queries

This document defines the Digital Credentials Query Language (DCQL) queries used in the EU Age Verification system, implementing the EU Age Verification Profile.

Related Documentation:

Overview

DCQL (Digital Credentials Query Language) is defined in OpenID4VP Section 6. It allows Relying Parties to specify which credentials and claims they require from the Wallet.

For age verification, we primarily request the age_over_18 claim from the EU Age Verification namespace.

Credential Namespaces

DCQL queries reference credential attributes using namespace paths. The primary namespaces for age verification are:

NamespaceDescriptionSpecification
eu.europa.ec.av.1EU Age Verification namespace (Proof of Age)EU Age Verification Profile
org.iso.18013.5.1ISO mDL namespace (Mobile Driver License)ISO/IEC 18013-5:2021

For complete attribute listings and encoding formats, see Credential Specifications.

Age Verification DCQL Query

Minimal Age Verification Request

The simplest age verification request asks only for age_over_18:

{
  "credentials": [
    {
      "id": "proof_of_age",
      "format": "mso_mdoc",
      "meta": {
        "doctype_value": "eu.europa.ec.av.1"
      },
      "claims": [
        {
          "path": ["eu.europa.ec.av.1", "age_over_18"]
        }
      ]
    }
  ]
}

Age Verification with Age Over 21

For jurisdictions requiring age 21+:

{
  "credentials": [
    {
      "id": "proof_of_age",
      "format": "mso_mdoc",
      "meta": {
        "doctype_value": "eu.europa.ec.av.1"
      },
      "claims": [
        {
          "path": ["eu.europa.ec.av.1", "age_over_21"]
        }
      ]
    }
  ]
}

Age Verification with Value Matching

Request age verification only if the claim value is true:

{
  "credentials": [
    {
      "id": "proof_of_age",
      "format": "mso_mdoc",
      "meta": {
        "doctype_value": "eu.europa.ec.av.1"
      },
      "claims": [
        {
          "path": ["eu.europa.ec.av.1", "age_over_18"],
          "values": [true]
        }
      ]
    }
  ]
}

Fallback: mDL Age Verification

If the user doesn’t have an EU Proof of Age but has an mDL:

{
  "credentials": [
    {
      "id": "age_from_mdl",
      "format": "mso_mdoc",
      "meta": {
        "doctype_value": "org.iso.18013.5.1.mDL"
      },
      "claims": [
        {
          "path": ["org.iso.18013.5.1", "age_over_18"]
        }
      ]
    }
  ]
}

Multiple Credential Options

Request age proof from either EU AV or mDL (Wallet chooses one):

{
  "credentials": [
    {
      "id": "eu_age_proof",
      "format": "mso_mdoc",
      "meta": {
        "doctype_value": "eu.europa.ec.av.1"
      },
      "claims": [
        {
          "path": ["eu.europa.ec.av.1", "age_over_18"]
        }
      ]
    },
    {
      "id": "mdl_age_proof",
      "format": "mso_mdoc",
      "meta": {
        "doctype_value": "org.iso.18013.5.1.mDL"
      },
      "claims": [
        {
          "path": ["org.iso.18013.5.1", "age_over_18"]
        }
      ]
    }
  ],
  "credential_sets": [
    {
      "options": [
        ["eu_age_proof"],
        ["mdl_age_proof"]
      ]
    }
  ]
}

Complete OpenID4VP Authorization Request

Same-Device Flow

GET /authorize?
  response_type=vp_token
  &response_mode=fragment
  &client_id=redirect_uri%3Ahttps%3A%2F%2Frp.example.com%2Fcallback
  &redirect_uri=https%3A%2F%2Frp.example.com%2Fcallback
  &nonce=n-0S6_WzA2Mj
  &dcql_query=%7B%22credentials%22%3A%5B%7B%22id%22%3A%22proof_of_age%22%2C%22format%22%3A%22mso_mdoc%22%2C%22meta%22%3A%7B%22doctype_value%22%3A%22eu.europa.ec.av.1%22%7D%2C%22claims%22%3A%5B%7B%22path%22%3A%5B%22eu.europa.ec.av.1%22%2C%22age_over_18%22%5D%7D%5D%7D%5D%7D

Decoded dcql_query:

{
  "credentials": [
    {
      "id": "proof_of_age",
      "format": "mso_mdoc",
      "meta": {
        "doctype_value": "eu.europa.ec.av.1"
      },
      "claims": [
        {
          "path": ["eu.europa.ec.av.1", "age_over_18"]
        }
      ]
    }
  ]
}

Cross-Device Flow

GET /authorize?
  response_type=vp_token
  &response_mode=direct_post
  &client_id=redirect_uri%3Ahttps%3A%2F%2Frp.example.com%2Fpost
  &response_uri=https%3A%2F%2Frp.example.com%2Fpost
  &nonce=n-0S6_WzA2Mj
  &state=eyJhb...6-sVA
  &dcql_query=...

Key differences from same-device:

  • response_mode=direct_post instead of fragment
  • response_uri instead of redirect_uri
  • state parameter for session correlation

VP Token Response

The Wallet returns a VP Token containing the presentation:

{
  "proof_of_age": ["<base64url-encoded DeviceResponse>"]
}

The DeviceResponse is a CBOR-encoded structure as defined in ISO/IEC 18013-5.

TypeScript Implementation Example

// Build DCQL query for age verification
function buildAgeVerificationQuery(minAge: 18 | 21 = 18): DCQLQuery {
  return {
    credentials: [
      {
        id: "proof_of_age",
        format: "mso_mdoc",
        meta: {
          doctype_value: "eu.europa.ec.av.1"
        },
        claims: [
          {
            path: ["eu.europa.ec.av.1", `age_over_${minAge}`]
          }
        ]
      }
    ]
  };
}

// Build complete authorization request
function buildAuthorizationRequest(
  flow: "same-device" | "cross-device",
  callbackUrl: string,
  minAge: 18 | 21 = 18
): OpenID4VPRequest {
  const nonce = crypto.randomUUID();
  const dcqlQuery = buildAgeVerificationQuery(minAge);
  
  if (flow === "same-device") {
    return {
      response_type: "vp_token",
      response_mode: "fragment",
      client_id: `redirect_uri:${callbackUrl}`,
      redirect_uri: callbackUrl,
      nonce,
      dcql_query: dcqlQuery
    };
  } else {
    return {
      response_type: "vp_token",
      response_mode: "direct_post",
      client_id: `redirect_uri:${callbackUrl}`,
      response_uri: callbackUrl,
      nonce,
      state: crypto.randomUUID(),
      dcql_query: dcqlQuery
    };
  }
}

References

Credential Specifications

This document covers both the technical construction of credential formats and the type-specific attribute schemas for all credential types used in this project.

The system supports credentials from the EUDI Wallet ecosystem, organized by category:

Government:

  • Mobile Driver’s License (mDL) — Full driving licence with optional age verification attributes
  • Person Identification Data (PID/National ID) — EU digital identity (mso_mdoc and SD-JWT VC)
  • Proof of Age (EU AV) — Dedicated privacy-preserving age verification attestation
  • Tax Identification — Tax number attestation (mso_mdoc and SD-JWT VC)
  • Pseudonym (Age Over 18) — Privacy-preserving age pseudonym (mso_mdoc and SD-JWT VC)
  • Certificate of Residence — Proof of residential address

Travel:

  • Photo ID — ISO 23220-2 photo identification
  • Travel Reservation — Booking/reservation attestation

Finance:

  • IBAN — Bank account attestation (mso_mdoc and SD-JWT VC)

Health:

  • European Health Insurance Card (EHIC) — Cross-border healthcare attestation (mso_mdoc and SD-JWT VC)
  • Health ID — Health insurance identification (mso_mdoc and SD-JWT VC)

Social Security:

  • Portable Document A1 (PDA1) — Social security coordination (mso_mdoc and SD-JWT VC)

Retail:

  • Loyalty Card — Retail loyalty programme attestation
  • MSISDN — Mobile phone number attestation (mso_mdoc and SD-JWT VC)

Other:

  • Power of Representation (PoR) — Legal representation attestation (mso_mdoc and SD-JWT VC)

The authoritative specifications are defined in:

  1. ISO/IEC 18013-5:2021 — For mDL (paid standard from ISO)
  2. ISO/IEC 23220-2 — For Photo ID
  3. EU Commission Implementing Regulation (CIR) 2024/2977 — For PID attributes
  4. EU Architecture and Reference Framework (ARF) — Attestation Rulebooks
  5. EU Age Verification Profile — For dedicated Proof of Age attestations
  6. IETF SD-JWT VC — For SD-JWT-based Verifiable Credentials

Related Documentation:


Authoritative Sources


Credential Formats

The EUDI Wallet ecosystem uses two credential formats. Many credential types are available in both.

Format Identifiers and DCQL Fields

Propertymso_mdoc (ISO/IEC 18013-5)dc+sd-jwt (SD-JWT VC)
DCQL format ID"mso_mdoc""dc+sd-jwt" (replaces earlier "vc+sd-jwt" since November 2024)
Type identifierdocType (e.g. eu.europa.ec.eudi.pid.1)vct claim (e.g. urn:eudi:pid:1)
Claim pathsNamespace-based: [namespace, claimName]Flat JSON: [claimName]
EncodingCBOR (RFC 8949) with COSE signaturesJSON with selective disclosure (JWS)
VP algorithm IDsCOSE integer identifiers (e.g. -7 for ES256)JOSE strings (e.g. "ES256")
DCQL meta fieldmeta.doctype_valuemeta.vct_values

Naming convention: mso_mdoc docType and namespace follow eu.europa.ec.eudi.<type>.1 (dot-separated). SD-JWT VC vct follows urn:eu.europa.ec.eudi:<type>:1 (colon-separated). Exception: PID uses the shorter urn:eudi:pid:1.


mso_mdoc Technical Reference

The mso_mdoc format (Mobile Security Object / mDoc) is defined in ISO/IEC 18013-5. It is binary (CBOR) and used for mDL, EU PID, EU Age Verification, and many other attestation types.

Overall Structure

An mDoc presentation consists of a DeviceResponse CBOR structure:

DeviceResponse = {
  "version": "1.0",
  "documents": [ Document+ ],          ; one or more documents
  "status": 0                          ; 0 = OK
}

Document = {
  "docType": tstr,                     ; e.g. "org.iso.18013.5.1.mDL"
  "issuerSigned": IssuerSigned,
  "deviceSigned": DeviceSigned
}

IssuerSigned and the MSO

IssuerSigned carries the issuer-authenticated data:

IssuerSigned = {
  "nameSpaces": IssuerNameSpaces,      ; disclosed claim values
  "issuerAuth": COSE_Sign1             ; the Mobile Security Object (MSO)
}

The MSO is a signed CBOR structure embedded as the payload of a COSE_Sign1. It contains digests of claim values rather than the values themselves:

MobileSecurityObject = {
  "version": "1.0",
  "digestAlgorithm": "SHA-256",
  "valueDigests": {
    "org.iso.18013.5.1": {
      0: bstr,    ; SHA-256 digest of IssuerSignedItemBytes for element 0
      1: bstr,    ; ...
      ...
    }
  },
  "deviceKeyInfo": { "deviceKey": COSE_Key },
  "docType": tstr,
  "validityInfo": { "signed": tdate, "validFrom": tdate, "validUntil": tdate }
}

IssuerSignedItem and Salted Hashing

Each claim is wrapped as an IssuerSignedItemBytes:

IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem)

IssuerSignedItem = {
  "digestID": uint,          ; matches the index in MSO valueDigests
  "random": bstr,            ; random salt (min 16 bytes)
  "elementIdentifier": tstr, ; claim name, e.g. "given_name"
  "elementValue": any        ; the claim value
}

The digest stored in the MSO is:

digest = SHA-256( cbor(IssuerSignedItemBytes) )
       = SHA-256( cbor(Tag(24, bstr(cbor(IssuerSignedItem)))) )

Important: the hash is computed over the full Tag(24, bstr(...)) encoding, not just the inner CBOR bytes.

Issuer Signing (issuerAuth)

The MSO is signed as a COSE_Sign1:

COSE_Sign1 = [
  protected: bstr .cbor { 1: alg },   ; e.g. alg = -7 (ES256)
  unprotected: { 33: [x5chain certs] },
  payload: bstr .cbor MobileSecurityObject,
  signature: bstr
]

The issuer certificate chain is carried in the x5chain (header label 33) unprotected header.

DeviceSigned and Device Authentication

DeviceSigned proves holder binding — that the presenting device controls the key registered with the credential:

DeviceSigned = {
  "nameSpaces": DeviceNameSpacesBytes,  ; Tag(24, bstr .cbor DeviceNameSpaces)
  "deviceAuth": DeviceAuth
}

DeviceAuth = {
  "deviceSignature": COSE_Sign1         ; or "deviceMac": COSE_Mac0
}

DeviceAuthentication Payload

The device COSE_Sign1 uses a detached payload called DeviceAuthenticationBytes:

DeviceAuthentication = [
  "DeviceAuthentication",
  SessionTranscript,          ; binds the presentation to the specific session
  DocType,                    ; e.g. "org.iso.18013.5.1.mDL"
  DeviceNameSpacesBytes       ; Tag(24, bstr .cbor DeviceNameSpaces)
]

DeviceAuthenticationBytes = #6.24(bstr .cbor DeviceAuthentication)

The wallet signs over DeviceAuthenticationBytes — the outer Tag(24, bstr(...)) wrapper must be included. The verifier must reconstruct identical bytes and use them as the COSE detached payload.

SessionTranscript and OpenID4VP Handover

For OpenID4VP presentations, SessionTranscript is:

SessionTranscript = [
  null,            ; DeviceEngagementBytes (absent for OID4VP)
  null,            ; EReaderKeyBytes (absent for OID4VP)
  OID4VPHandover
]

OID4VPHandover = [
  "OpenID4VPHandover",
  SHA-256( cbor(OID4VPHandoverInfo) )
]

OID4VPHandoverInfo = [
  clientId,             ; the RP's client_id (e.g. "redirect_uri:https://..." or "x509_san_dns:...")
  nonce,                ; from the Authorization Request
  jwkThumbprint,        ; bstr | null — SHA-256 JWK thumbprint of verifier's encryption key
  responseUri           ; the response_uri from the Authorization Request
]

The jwkThumbprint is present only when response_mode=direct_post.jwt (JARM encryption). For plain direct_post, it is CBOR null.

COSE Algorithms

COSE alg IDNameDescription
-7ES256ECDSA with P-256, SHA-256
-35ES384ECDSA with P-384, SHA-384
-36ES512ECDSA with P-521, SHA-512
-37PS256RSASSA-PSS with SHA-256
-257RS256RSASSA-PKCS1-v1_5 SHA-256
5HMAC256HMAC with SHA-256 (MAC auth)

mso_mdoc Encoding Rules

  1. String encoding: tstr SHALL be UTF-8, max 150 characters
  2. Date encoding:
    • full-date = #6.1004(tstr) per RFC 8943 (YYYY-MM-DD)
    • tdate = RFC 3339 datetime string
  3. Timestamps: No fractional seconds; offset SHALL be "Z" (UTC)
  4. CBOR canonical rules:
    • Integers as small as possible
    • Length expressions as short as possible
    • Definite-length items only

SD-JWT VC Technical Reference

SD-JWT VC (Selective Disclosure JWT for Verifiable Credentials) is defined in IETF SD-JWT VC. It uses JSON/JWS and is used for PID, cross-device credentials, and other EUDI attestation types.

Overall Structure

An SD-JWT VC presentation is a tilde-separated string:

<Issuer-signed JWT>~<Disclosure_1>~<Disclosure_2>~...~<KB-JWT>
  • Issuer-signed JWT: standard JWS containing _sd arrays of digests
  • Disclosures: base64url-encoded JSON arrays [salt, claim_name, claim_value]
  • KB-JWT (Key Binding JWT): proves holder control (required for wallet presentations)

Issuer-Signed JWT

{
  "alg": "ES256",
  "typ": "vc+sd-jwt"
}
.
{
  "iss": "https://issuer.example.com",
  "iat": 1700000000,
  "exp": 1800000000,
  "vct": "https://credentials.example.com/identity_credential",
  "cnf": { "jwk": { ... } },      // holder's public key (holder binding)
  "_sd_alg": "sha-256",
  "_sd": [
    "X9yH0Ajf...",   // SHA-256 digest of Disclosure for "given_name"
    "aB3kLm9n...",   // SHA-256 digest of Disclosure for "family_name"
    ...
  ],
  "age_equal_or_over": {
    "_sd": [ "qR7sT2uV..." ]   // nested selective disclosure
  }
}
.
<signature>

Disclosures

Each selectively-disclosed claim is represented as a Disclosure:

Disclosure = BASE64URL( JSON([ salt, claim_name, claim_value ]) )

Example (decoded):
[ "dX23abc_SALT_VALUE", "given_name", "Elton" ]

The digest embedded in the JWT is:

digest = BASE64URL( SHA-256( ASCII(Disclosure) ) )

To reveal a claim, the holder includes the corresponding Disclosure in the presentation. The verifier recomputes the digest and checks it against the _sd array.

Key Binding JWT (KB-JWT)

The KB-JWT proves that the holder controls the private key corresponding to cnf.jwk in the issuer JWT, and binds the presentation to a specific transaction:

{
  "alg": "ES256",
  "typ": "kb+jwt"
}
.
{
  "iat": 1700000100,
  "aud": "https://verifier.example.com",   // client_id of the RP
  "nonce": "abc123",                        // nonce from Authorization Request
  "sd_hash": "BASE64URL(SHA-256(issuer_jwt~disc1~disc2~))"  // commitment
}
.
<holder_signature>

sd_hash is SHA-256 of the SD-JWT string up to and including the last ~ before the KB-JWT. This prevents replaying the KB-JWT with a different set of disclosures.

See OpenID4VP §5.3 for Holder Binding Proof requirements.

SD-JWT VC Signing Algorithms

AlgorithmKey TypeHash Algorithm
ES256P-256SHA-256
ES384P-384SHA-384
RS256RSA-2048PKCS#1 v1.5 SHA-256
PS256RSA-2048RSASSA-PSS SHA-256

SD-JWT VC Encoding Rules

  1. Type claim: vct SHALL be urn:eudi:pid:1 (or domestic extension)
  2. Date encoding: ISO 8601-1 YYYY-MM-DD format
  3. Technical validity: Use standard JWT claims nbf and exp
  4. Hierarchical claims: Use dot notation (e.g. address.country)

Format Comparison

Propertymso_mdocSD-JWT VC
EncodingCBOR (binary)JSON / Base64URL
ContainerDeviceResponse<jwt>~<disc>~...~<kb-jwt>
Issuer signatureCOSE_Sign1 (EC/RSA)JWS (EC/RSA)
Selective disclosureSalted SHA-256 per IssuerSignedItemSalted SHA-256 per Disclosure
Holder bindingDeviceSigned (COSE_Sign1 / COSE_Mac0)KB-JWT (JWS)
Session bindingSessionTranscript in DeviceAuthBytesaud + nonce in KB-JWT
Multi-documentYes (documents array)One credential per presentation
Binary-friendlyYes (native CBOR)Base64URL encoding needed
Primary standardISO/IEC 18013-5IETF SD-JWT VC + OpenID4VP

Verification Steps

mso_mdoc Verification

  1. Decode the DeviceResponse from base64url → CBOR
  2. For each Document:
    • Decode the issuerAuth COSE_Sign1
    • Verify the issuer certificate chain (x5chain header) up to a trusted root
    • Verify the COSE_Sign1 signature over the MSO payload
    • Check MSO expiry, validFrom, docType
    • For each disclosed IssuerSignedItem:
      • Re-encode as IssuerSignedItemBytes = Tag(24, bstr(cbor(IssuerSignedItem)))
      • Compute SHA-256(cbor(IssuerSignedItemBytes))
      • Verify it matches MSO valueDigests[namespace][digestID]
    • Reconstruct SessionTranscript from the Authorization Request parameters
    • Build DeviceAuthenticationDeviceAuthenticationBytes = Tag(24, bstr(cbor(DeviceAuthentication)))
    • Verify the deviceSignature COSE_Sign1 with the MSO deviceKey over DeviceAuthenticationBytes

SD-JWT VC Verification

  1. Split the SD-JWT on ~ into: issuer JWT, disclosures, KB-JWT
  2. Verify the issuer JWT signature using the issuer’s public key (from iss metadata or x5c header)
  3. Check standard JWT claims (exp, nbf, iss, vct)
  4. For each presented Disclosure:
    • Compute BASE64URL(SHA-256(disclosure_string))
    • Verify the digest appears in the issuer JWT’s _sd array (recursively for nested claims)
  5. Verify the KB-JWT signature using cnf.jwk from the issuer JWT
  6. Check KB-JWT aud matches client_id, nonce matches the request nonce
  7. Check sd_hash = BASE64URL(SHA-256(issuer_jwt~disc1~disc2~...))

Credential Types Summary

Type IDNameFormatdocType / vctAge Verification
mdlMobile Driver’s Licensemso_mdocorg.iso.18013.5.1.mDLage_over_18, age_over_21
national-idNational ID (PID)mso_mdoceu.europa.ec.eudi.pid.1
national-id-sd-jwtNational ID (PID)dc+sd-jwturn:eudi:pid:1
proof-of-ageProof of Age (EU AV)mso_mdoceu.europa.ec.av.1age_over_18 only
taxTax Identificationmso_mdoceu.europa.ec.eudi.tax.1
tax-sd-jwtTax Identificationdc+sd-jwturn:eu.europa.ec.eudi:tax:1
pseudonym-agePseudonym (Age Over 18)mso_mdoceu.europa.ec.eudi.pseudonym.age_over_18.1age_over_18 only
pseudonym-age-sd-jwtPseudonym (Age Over 18)dc+sd-jwturn:eu.europa.ec.eudi:pseudonym_age_over_18:1age_over_18 only
corCertificate of Residencemso_mdoceu.europa.ec.eudi.cor.1
photo-idPhoto IDmso_mdocorg.iso.23220.2.photoid.1
reservationTravel Reservationmso_mdocorg.iso.18013.5.1.reservation
ibanIBANmso_mdoceu.europa.ec.eudi.iban.1
iban-sd-jwtIBANdc+sd-jwturn:eu.europa.ec.eudi:iban:1
ehicEHICmso_mdoceu.europa.ec.eudi.ehic.1
ehic-sd-jwtEHICdc+sd-jwturn:eu.europa.ec.eudi:ehic:1
health-idHealth IDmso_mdoceu.europa.ec.eudi.hiid.1
health-id-sd-jwtHealth IDdc+sd-jwturn:eu.europa.ec.eudi:hiid:1
pda1Portable Document A1mso_mdoceu.europa.ec.eudi.pda1.1
pda1-sd-jwtPortable Document A1dc+sd-jwturn:eu.europa.ec.eudi:pda1:1
loyaltyLoyalty Cardmso_mdoceu.europa.ec.eudi.loyalty.1
msisdnMSISDNmso_mdoceu.europa.ec.eudi.msisdn.1
msisdn-sd-jwtMSISDNdc+sd-jwturn:eu.europa.ec.eudi:msisdn:1
porPower of Representationmso_mdoceu.europa.ec.eudi.por.1
por-sd-jwtPower of Representationdc+sd-jwturn:eu.europa.ec.eudi:por:1

Webapp UI Credential Selection

The Relying Party Demo Webapp offers four credential types for selection:

CredentialFormatProfile
Proof of AgeMSO MDOCAnnex A
Mobile Driver’s LicenseMSO MDOCHAIP
National ID (PID)MSO MDOCHAIP
Health IDSD-JWT VCHAIP

Implementation

The configuration in js-lib/ewqwe-digital-identity/src/config.ts defines all credential types with their format, identifiers, and claims. The DCQL query builder in js-lib/ewqwe-digital-identity/src/dcql.ts handles both formats:

// mso_mdoc: namespace-based claim paths
{ id: "age_over_18", path: ["org.iso.18013.5.1", "age_over_18"] }

// dc+sd-jwt: flat JSON claim paths
{ id: "family_name", path: ["family_name"] }

1. Mobile Driver’s License (mDL)

Document Type

org.iso.18013.5.1.mDL

Namespace

org.iso.18013.5.1

Specification

The mDL data model is fully specified in ISO/IEC 18013-5:2021. Within the EUDI Wallet ecosystem:

  • mDLs SHALL comply with ISO/IEC 18013-5
  • mDLs SHALL NOT be implemented as SD-JWT VC-compliant attestations (per the 4th Driving Licence Regulation)
  • Encoding uses CBOR per RFC 8949

mDL Attributes (namespace org.iso.18013.5.1)

Attribute IdentifierDescriptionPresenceEncoding
family_nameCurrent family name(s) or surname(s)Mandatorytstr (UTF-8, max 150 chars)
given_nameCurrent first name(s), including middle name(s)Mandatorytstr
birth_dateDate of birthMandatoryfull-date (RFC 8943, tag 1004)
portraitFacial image of the holderMandatorybstr (JPEG, ISO 19794-5)
issue_dateDate of mDL issuanceMandatorytdate or full-date
expiry_dateDate of mDL expiryMandatorytdate or full-date
issuing_authorityAuthority that issued the mDLMandatorytstr
issuing_countryCountry code (ISO 3166-1 alpha-2)Mandatorytstr
document_numberUnique document identifierOptionaltstr
driving_privilegesCategories and restrictionsMandatoryComplex type (see below)
un_distinguishing_signUN distinguishing sign of issuing countryOptionaltstr
administrative_numberAdministrative number for the documentOptionaltstr
sexSex (0=unknown, 1=male, 2=female, 9=N/A)Optionaluint
heightHeight in centimetresOptionaluint
weightWeight in kilogramsOptionaluint
eye_colourEye colourOptionaltstr
hair_colourHair colourOptionaltstr
birth_placePlace of birthOptionaltstr
resident_addressCurrent addressOptionaltstr
resident_cityCity of residenceOptionaltstr
resident_stateState/province of residenceOptionaltstr
resident_postal_codePostal codeOptionaltstr
resident_countryCountry of residence (ISO 3166-1 alpha-2)Optionaltstr
age_over_18Whether holder is over 18Optionalbool
age_over_21Whether holder is over 21Optionalbool
age_over_NNWhether holder is over NN yearsOptionalbool
age_in_yearsAge in yearsOptionaluint
age_birth_yearYear of birthOptionaluint
nationalityNationalityOptionaltstr

Driving Privileges Structure

driving_privileges = [* DrivingPrivilege]

DrivingPrivilege = {
  "vehicle_category_code": tstr,
  ? "issue_date": full-date,
  ? "expiry_date": full-date,
  ? "codes": [* Code]
}

Code = {
  "code": tstr,
  ? "sign": tstr,
  ? "value": tstr
}

2. Person Identification Data (PID) / National ID

Document Type (mso_mdoc)

eu.europa.ec.eudi.pid.1

Namespace (mso_mdoc)

eu.europa.ec.eudi.pid.1

Verifiable Credential Type (dc+sd-jwt)

urn:eudi:pid:1

PID Specification

PID attributes are defined in CIR 2024/2977 and the EU ARF PID Rulebook. PIDs:

  • SHALL be issued in both ISO/IEC 18013-5 format AND SD-JWT VC format
  • Use namespace eu.europa.ec.eudi.pid.1 for ISO format
  • Use vct claim urn:eudi:pid:1 for SD-JWT VC format

Mandatory Attributes (CIR 2024/2977)

Data IdentifierISO Attribute IDSD-JWT ClaimDescriptionEncoding (ISO)Encoding (SD-JWT)
family_namefamily_namefamily_nameCurrent surname(s)tstrstring
given_namegiven_namegiven_nameCurrent first/middle name(s)tstrstring
birth_datebirth_datebirthdateDate of birth (YYYY-MM-DD)full-datestring (ISO 8601-1)
birth_placeplace_of_birthplace_of_birthPlace of birthplace_of_birthJSON object
nationalitynationalitynationalitiesNationality (ISO 3166-1 alpha-2)nationalitiesarray of strings

Optional Attributes (CIR 2024/2977)

Data IdentifierISO Attribute IDSD-JWT ClaimDescriptionEncoding (ISO)Encoding (SD-JWT)
resident_addressresident_addressaddress.formattedFull current addresststrstring
resident_countryresident_countryaddress.countryCountry of residencetstrstring
resident_stateresident_stateaddress.regionState/provincetstrstring
resident_cityresident_cityaddress.localityCity/towntstrstring
resident_postal_coderesident_postal_codeaddress.postal_codePostal codetstrstring
resident_streetresident_streetaddress.street_addressStreet nametstrstring
resident_house_numberresident_house_numberaddress.house_numberHouse numbertstrstring
personal_administrative_numberpersonal_administrative_numberpersonal_administrative_numberUnique PID numbertstrstring
portraitportraitpictureFacial image (JPEG, ISO 19794-5)bstrdata URL (base64)
family_name_birthfamily_name_birthbirth_family_nameSurname at birthtstrstring
given_name_birthgiven_name_birthbirth_given_nameFirst name at birthtstrstring
sexsexsexSex (0-9, see ISO 5218)uintnumber
email_addressemail_addressemailEmail address (RFC 5322)tstrstring
mobile_phone_numbermobile_phone_numberphone_numberMobile phone (+country code)tstrstring

Mandatory Metadata (CIR 2024/2977)

Data IdentifierISO Attribute IDSD-JWT ClaimDescription
expiry_dateexpiry_datedate_of_expiryAdministrative expiry date
issuing_authorityissuing_authorityissuing_authorityIssuing authority name
issuing_countryissuing_countryissuing_countryIssuing country (ISO 3166-1 alpha-2)

Optional Metadata (CIR 2024/2977)

Data IdentifierISO Attribute IDSD-JWT ClaimDescription
document_numberdocument_numberdocument_numberPID document number
issuing_jurisdictionissuing_jurisdictionissuing_jurisdictionJurisdiction (ISO 3166-2)
issuance_dateissuance_datedate_of_issuanceDate of issuance

Complex Type Definitions

place_of_birth (ISO format)

place_of_birth = {
  ? "country": tstr,   ; ISO 3166-1 alpha-2 country code
  ? "region": tstr,    ; state, province, district
  ? "locality": tstr   ; municipality, city, town, village
}
; At least one of country, region, or locality SHALL be present

nationalities (ISO format)

nationalities = [+ CountryCode]
CountryCode = tstr  ; ISO 3166-1 alpha-2 country code

Authoritative Requirements (EU ARF Annex 2.02 Topic 3)

RequirementSpecification
PID_04“PID Providers SHALL use eu.europa.ec.eudi.pid.1 as the attestation type for ISO/IEC 18013-5-compliant PIDs.”
PID_05“When issuing a PID compliant with [ISO/IEC 18013-5], a PID Provider SHALL use the value eu.europa.ec.eudi.pid.1 for the identifier of the namespace for the PID attributes.”
PID_14“A PID Provider issuing [SD-JWT VC]-compliant PIDs SHALL include the vct claim… The type indicated by the vct claim SHALL be urn:eudi:pid:1

Important Note on Age Verification

Per the EU ARF PID Rulebook changelog (v1.1): “Age verification attributes removed, following CIR 2024/2977”

age_over_18 and age_over_21 are NOT valid PID attributes under EU regulations. For age verification, use:

  1. mDL (org.iso.18013.5.1.mDL) — Contains age_over_18, age_over_21 as optional attributes
  2. Proof of Age (eu.europa.ec.av.1) — Dedicated privacy-preserving attestation with only age_over_18

3. Proof of Age Attestation (EU Age Verification)

Document Type

eu.europa.ec.av.1

Namespace

eu.europa.ec.av.1

Specification

The Proof of Age attestation is defined in the EU Age Verification Profile. Key characteristics:

  • Format: ISO mDoc only — NOT SD-JWT VC
  • Purpose: Privacy-preserving age verification with minimal data disclosure
  • Single attribute: Contains only age_over_18 boolean
  • No personal data: Does not store any identity information

Attributes

Attribute IdentifierDescriptionPresenceEncoding
age_over_18Whether holder is over 18Mandatorybool

Protocol Stack

ProtocolUsageSpecification
IssuanceOpenID4VCI with credential_configuration_ids: ["proof_of_age"]OpenID for Verifiable Credential Issuance
Presentation (Primary)W3C Digital Credentials APIISO/IEC 18013-7 Annex C
Presentation (Fallback)OpenID4VP with response_mode=direct_postOpenID for Verifiable Presentations

DCQL Query Example

{
  "credentials": [{
    "id": "proof_of_age",
    "format": "mso_mdoc",
    "meta": { "doctype_value": "eu.europa.ec.av.1" },
    "claims": [{"path": ["eu.europa.ec.av.1", "age_over_18"]}]
  }]
}

4. Tax Identification

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.tax.1
dc+sd-jwturn:eu.europa.ec.eudi:tax:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.tax.1

Claims

Claim IDNameDescription
tax_numberTax NumberTax identification number
registered_family_nameRegistered Family NameFamily name registered with tax authority
registered_given_nameRegistered Given NamesGiven names registered with tax authority
issuing_countryIssuing CountryCountry code (ISO 3166-1 alpha-2)

5. Pseudonym (Age Over 18)

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.pseudonym.age_over_18.1
dc+sd-jwturn:eu.europa.ec.eudi:pseudonym_age_over_18:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.pseudonym.age_over_18.1

Claims

Claim IDNameDescriptionEncoding
age_over_18Age Over 18Whether holder is over 18bool

Provides a privacy-preserving pseudonymous age attestation, disclosing only age_over_18 without any identity information.


6. Certificate of Residence

Document Type

eu.europa.ec.eudi.cor.1

Namespace

eu.europa.ec.eudi.cor.1

Format

mso_mdoc only.

Claims

Claim IDNameDescription
resident_addressResident AddressFull residential address
resident_countryResident CountryCountry of residence (ISO 3166-1)
resident_cityResident CityCity of residence
resident_postal_codeResident Postal CodePostal code
issuing_countryIssuing CountryCountry code (ISO 3166-1 alpha-2)

7. Photo ID

Document Type

org.iso.23220.2.photoid.1

Namespace

org.iso.23220.photoid.1

Specification

Photo ID is defined in ISO/IEC 23220-2 and provides a general-purpose photo identification credential.

Format

mso_mdoc only.

Claims

Claim IDNameDescription
family_nameFamily NameCurrent surname(s)
given_nameGiven NamesCurrent first/middle name(s)
birth_dateBirth DateDate of birth
portraitPortraitFacial image of the holder
document_numberDocument NumberUnique document identifier
issuing_authorityIssuing AuthorityAuthority that issued the document
issuing_countryIssuing CountryCountry code (ISO 3166-1 alpha-2)
expiry_dateExpiry DateDate of document expiry

8. Travel Reservation

Document Type

org.iso.18013.5.1.reservation

Namespace

org.iso.18013.5.1.reservation

Format

mso_mdoc only.

Claims

Claim IDNameDescription
reservation_numberReservation NumberBooking/reservation identifier
family_nameFamily NamePassenger surname(s)
given_nameGiven NamesPassenger first/middle name(s)

9. IBAN

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.iban.1
dc+sd-jwturn:eu.europa.ec.eudi:iban:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.iban.1

Claims

Claim IDNameDescription
ibanIBANInternational Bank Account Number
account_holderAccount HolderName of the account holder
bicBICBank Identifier Code

10. European Health Insurance Card (EHIC)

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.ehic.1
dc+sd-jwturn:eu.europa.ec.eudi:ehic:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.ehic.1

Claims

Claim IDNameDescription
family_nameFamily NameHolder’s surname(s)
given_nameGiven NamesHolder’s first/middle name(s)
birth_dateBirth DateDate of birth
personal_idPersonal IDPersonal identification number
institution_idInstitution IDEHIC institution identifier
institution_countryInstitution CountryCountry of the insuring institution
card_numberCard NumberEHIC card number
expiry_dateExpiry DateCard expiry date

11. Health ID

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.hiid.1
dc+sd-jwturn:eu.europa.ec.eudi:hiid:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.hiid.1

Claims

Claim IDNameDescription
family_nameFamily NameHolder’s surname(s)
given_nameGiven NamesHolder’s first/middle name(s)
birth_dateBirth DateDate of birth
health_insurance_idHealth Insurance IDHealth insurance identifier
issuing_countryIssuing CountryCountry code (ISO 3166-1 alpha-2)

12. Portable Document A1 (PDA1)

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.pda1.1
dc+sd-jwturn:eu.europa.ec.eudi:pda1:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.pda1.1

Specification

The Portable Document A1 (PDA1) is a social security coordination document certifying the applicable social security legislation to the holder, typically when working in another EU member state.

Claims

Claim IDNameDescription
family_nameFamily NameHolder’s surname(s)
given_nameGiven NamesHolder’s first/middle name(s)
birth_dateBirth DateDate of birth
nationalityNationalityNationality (ISO 3166-1 alpha-2)
social_security_numberSocial Security NumberSocial security identification number
issuing_countryIssuing CountryCountry code (ISO 3166-1 alpha-2)
expiry_dateExpiry DateDocument expiry date

13. Loyalty Card

Document Type

eu.europa.ec.eudi.loyalty.1

Namespace

eu.europa.ec.eudi.loyalty.1

Format

mso_mdoc only.

Claims

Claim IDNameDescription
family_nameFamily NameHolder’s surname(s)
given_nameGiven NamesHolder’s first/middle name(s)
loyalty_numberLoyalty NumberLoyalty programme number
program_nameProgram NameName of the loyalty programme

14. Mobile Phone Number (MSISDN)

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.msisdn.1
dc+sd-jwturn:eu.europa.ec.eudi:msisdn:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.msisdn.1

Claims

Claim IDNameDescription
phone_numberPhone NumberMobile phone number (MSISDN)
registered_family_nameRegistered Family NameFamily name registered with carrier

15. Power of Representation (PoR)

Document Types

FormatIdentifier
mso_mdoceu.europa.ec.eudi.por.1
dc+sd-jwturn:eu.europa.ec.eudi:por:1 (vct)

Namespace (mso_mdoc)

eu.europa.ec.eudi.por.1

Claims

Claim IDNameDescription
legal_person_idLegal Person IDIdentifier of the legal entity
legal_person_nameLegal Person NameName of the legal entity
representative_family_nameRepresentative Family NameSurname of the representative
representative_given_nameRepresentative Given NamesGiven names of the representative

References

  1. ISO/IEC 18013-5:2021 — Personal identification — ISO-compliant driving licence — Part 5: Mobile driving licence (mDL) application

  2. Commission Implementing Regulation (EU) 2024/2977 — Rules on PID and EAA

  3. EU Architecture and Reference Framework (ARF)

  4. RFC 8949 — Concise Binary Object Representation (CBOR)

  5. RFC 8943 — CBOR Tags for Date

  6. RFC 8610 — Concise Data Definition Language (CDDL)

  7. RFC 7515 — JSON Web Signature (JWS)

  8. RFC 8152 — CBOR Object Signing and Encryption (COSE)

  9. SD-JWT VC — SD-JWT-based Verifiable Credentials (IETF draft)

  10. OpenID4VP 1.0 — OpenID for Verifiable Presentations

  11. OpenID Connect Core 1.0 — Standard Claims

  12. OpenID Connect for Identity Assurance — Extended claims

See the centralized reference list for all authoritative sources used throughout this documentation.

ISO/IEC 18013-5 + 18013-7 DCAPI (Age Verification)

This chapter explains the ISO mDoc request/response format used with the W3C Digital Credentials API in the EU Age Verification Profile Annex A, A.5 (Proof of Age attestation presentation).

It focuses on:

  • ISO/IEC 18013-7, Annex C: how Digital Credentials API requests/responses carry an ISO mDoc transaction.
  • ISO/IEC 18013-5, §8.3.2.1.2.1: the DeviceRequest format (what the Relying Party requests).
  • ISO/IEC 18013-5, §8.3.2.1.2.3: the DeviceResponse format (what the Wallet returns).

Why Annex A.5 mentions ISO 18013-7 Annex C

Annex A.5 uses the W3C Digital Credentials API as the browser-facing mechanism for credential selection and exchange. ISO/IEC 18013-7 defines a transport binding that describes how an mDoc exchange is packaged for that API.

In other words:

  • The browser API call is defined by W3C (navigator.credentials.get() with protocol: "org-iso-mdoc").
  • The credential message format is defined by ISO mDoc.
  • The wrapper + encryption packaging (so the browser sees opaque bytes) is defined by ISO/IEC 18013-7 Annex C.

The "org-iso-mdoc" protocol identifier tells the browser’s credential manager to route the request to wallets that support ISO mDoc presentations (such as France Identité). This is the convention used in the France Identité playground and the EUDIW Playground marketplace.

Mental model (high level)

A conforming Relying Party builds a request with two base64url-encoded CBOR blobs:

  • encryptionInfo: tells the wallet how to encrypt the response back to the RP.
  • deviceRequest: tells the wallet what to present (ISO mDoc DeviceRequest).

The wallet produces a response as a base64url-encoded CBOR blob containing:

  • an HPKE-encrypted ISO mDoc DeviceResponse.

Normative requirements (what you must do)

The following is a practical, implementation-oriented reading of the ISO requirements referenced by Annex A.5.

1) Use CBOR as the binary format

  • encryptionInfo, deviceRequest, and the final EncryptedResponse are CBOR-encoded.
  • CBOR is then encoded as base64url without padding.

2) Bind request and response with a nonce

  • The RP includes a cryptographic nonce in encryptionInfo.
  • The wallet uses it in the cryptographic context so the response cannot be replayed in a different session.

3) Provide a COSE_Key for the wallet to encrypt to

  • The RP includes a recipientPublicKey as a COSE_Key structure.
  • The wallet uses that key for HPKE encryption (RFC 9180) of the DeviceResponse.

4) The response is an HPKE-encrypted DeviceResponse

  • The wallet returns an object that contains an HPKE ephemeral public key (commonly exposed as enc) and the HPKE ciphertext (cipherText).
  • After HPKE decryption, the RP obtains an ISO mDoc DeviceResponse.

ISO/IEC 18013-7 Annex C wrapper (DCAPI packaging)

ISO/IEC 18013-7 Annex C defines a simple wrapper that tags the payload for the Digital Credentials API use-case. A common representation used in profiles is:

  • encryptionInfo = base64url_nopad(CBOR(["dcapi", { nonce, recipientPublicKey }]))
  • deviceRequest = base64url_nopad(CBOR(DeviceRequest))
  • response = base64url_nopad(CBOR(["dcapi", { enc, cipherText }]))

Where:

  • nonce is a CBOR byte string (bstr).
  • recipientPublicKey is a COSE_Key.
  • enc is the HPKE sender’s ephemeral public key (the RP needs it to decrypt).
  • cipherText is the HPKE ciphertext of the CBOR-encoded DeviceResponse.

ISO/IEC 18013-5 §8.3.2.1.2.1: DeviceRequest (what the RP asks for)

The DeviceRequest expresses:

  • Which document type(s) you accept (e.g. PID, mDL).
  • Which namespace(s) and element identifiers you request.
  • Whether you request issuer-signed and/or device-signed data.
  • Session binding and reader authentication mechanisms (where applicable).

Age Verification-specific guidance

For age verification, the most important implementation rule is data minimization:

  • Request only what you need to determine an “over age” result.
  • Avoid requesting full identity attributes (name, address, portrait) if your policy does not require them.

In an EU Age Verification Profile context, that typically means requesting either:

  • A Proof of Age attestation namespace/claims (preferred), or
  • A minimal set of attributes sufficient to compute an age threshold (fallback / legacy).

ISO/IEC 18013-5 §8.3.2.1.2.3: DeviceResponse (what the Wallet returns)

The DeviceResponse includes the presentation data and cryptographic material needed for verification:

  • Document(s) returned by the wallet.
  • Issuer-signed data and potentially device-signed data.
  • Integrity protection structures defined by ISO mDoc.

In the DCAPI flow, the DeviceResponse is not returned in plaintext: it is embedded inside cipherText and must be HPKE-decrypted first.

End-to-end flow (sequence)

  1. RP generates a fresh HPKE recipient key pair (or equivalent) and a fresh random nonce.
  2. RP builds encryptionInfo (CBOR → base64url no pad).
  3. RP builds DeviceRequest (CBOR → base64url no pad) with age-verification-minimal queries.
  4. RP calls the W3C Digital Credentials API with those parameters.
  5. Wallet parses both blobs, prepares DeviceResponse.
  6. Wallet HPKE-encrypts the DeviceResponse to the RP public key and returns EncryptedResponse (CBOR → base64url no pad).
  7. RP base64url-decodes + CBOR-decodes the wrapper, then HPKE-decrypts cipherText using enc.
  8. RP CBOR-decodes the resulting DeviceResponse and verifies it (issuer trust, signatures, validity).

Implementation examples

The examples below are intentionally explicit about the byte transformations (CBOR ↔ base64url) and the HPKE boundary.

Example 1: TypeScript (RP side) – build request, decrypt response

This is illustrative pseudocode showing the structure and steps; you can adapt it to Deno/Node.

import { encode as cborEncode, decode as cborDecode } from "cbor-x";
import { base64url } from "rfc4648";

// HPKE libraries vary; treat these as placeholders.
import {
  generateHpkeKeyPair,
  hpkeOpen,
} from "./hpke_placeholder.ts";

type CoseKey = Record<string, unknown>;

function b64urlNoPad(bytes: Uint8Array): string {
  return base64url.stringify(bytes, { pad: false });
}

function b64urlNoPadToBytes(s: string): Uint8Array {
  return base64url.parse(s, { loose: true });
}

// 1) Prepare recipient public key (COSE_Key) + nonce
const { privateKey, publicKeyCoseKey } = await generateHpkeKeyPair();
const nonce = crypto.getRandomValues(new Uint8Array(16));

// 2) Build encryptionInfo wrapper
const encryptionInfoCbor = cborEncode([
  "dcapi",
  {
    nonce, // bstr in CBOR
    recipientPublicKey: publicKeyCoseKey as CoseKey,
  },
]);
const encryptionInfo = b64urlNoPad(encryptionInfoCbor);

// 3) Build DeviceRequest (structure is ISO-defined; keep this minimal for age verification)
const deviceRequestCbor = cborEncode({
  // NOTE: This is a simplified, schematic shape.
  // In real implementations it must match ISO/IEC 18013-5 §8.3.2.1.2.1.
  version: "1.0",
  docRequests: [
    {
      docType: "eu.europa.ec.av.1", // example: Proof of Age attestation
      itemsRequest: {
        // request only the minimum required elements
        nameSpaces: {
          "eu.europa.ec.av.1": {
            age_over_18: true,
          },
        },
      },
    },
  ],
});
const deviceRequest = b64urlNoPad(deviceRequestCbor);

// 4) Call Digital Credentials API (sketch)
// const resp = await navigator.credentials.get({
//   digital: {
//     requests: [{ encryptionInfo, deviceRequest }],
//   },
// });

// Assume the wallet gives us a base64url response string:
const encryptedResponseB64Url = "...";

// 5) Decode EncryptedResponse wrapper
const encryptedResponseBytes = b64urlNoPadToBytes(encryptedResponseB64Url);
const [tag, data] = cborDecode(encryptedResponseBytes) as [string, any];
if (tag !== "dcapi") throw new Error("Unexpected response tag");

const enc = data.enc as Uint8Array;        // sender ephemeral key
const cipherText = data.cipherText as Uint8Array;

// 6) HPKE decrypt to recover DeviceResponse bytes
const deviceResponseBytes = await hpkeOpen({
  recipientPrivateKey: privateKey,
  enc,
  cipherText,
  // Depending on suite/profile you may also bind AAD/info.
});

// 7) CBOR-decode DeviceResponse
const deviceResponse = cborDecode(deviceResponseBytes);
console.log(deviceResponse);

Example 2: Rust (RP side) – decode wrapper and HPKE decrypt

This is a compact sketch of the RP-side decode/decrypt steps.

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use ciborium::de::from_reader;
use hpke::{
    aead::AesGcm128,
    kdf::HkdfSha256,
    kem::X25519HkdfSha256,
    Deserializable, OpModeR,
};

fn b64url_no_pad_decode(s: &str) -> Vec<u8> {
    URL_SAFE_NO_PAD.decode(s.as_bytes()).expect("b64url")
}

// This assumes you already have the recipient private key in the HPKE type.
// Real code must also parse COSE_Key and map it to the HPKE KEM.
fn main() {
    let encrypted_response_b64 = "...";
    let bytes = b64url_no_pad_decode(encrypted_response_b64);

    // EncryptedResponse = ["dcapi", { enc: bstr, cipherText: bstr }]
    let mut cursor = std::io::Cursor::new(bytes);
    let decoded: ciborium::Value = from_reader(&mut cursor).expect("cbor");

    // Parse out `enc` and `cipherText` from decoded CBOR...
    // Then HPKE open:

    // let (enc_bytes, ciphertext_bytes) = ...;
    // let enc = <hpke::kem::X25519HkdfSha256 as hpke::Kem>::EncappedKey::from_bytes(&enc_bytes).unwrap();
    // let recipient_sk = ...;

    // let mut pt = vec![0u8; ciphertext_bytes.len()];
    // let (_ctx, pt_len) = hpke::single_shot_open::<AesGcm128, HkdfSha256, X25519HkdfSha256, _>(
    //     &OpModeR::Base,
    //     &recipient_sk,
    //     &enc,
    //     b"", // info
    //     &ciphertext_bytes,
    //     b"", // aad
    //     &mut pt,
    // ).expect("hpke open");
    // pt.truncate(pt_len);

    // Finally CBOR-decode DeviceResponse from `pt`.
}

Example 3: Wallet side (conceptual)

A wallet implementation typically:

  • Parses encryptionInfo and deviceRequest.
  • Selects appropriate credential(s).
  • Builds DeviceResponse and CBOR-encodes it.
  • Uses HPKE with recipientPublicKey to encrypt to cipherText.
  • Emits EncryptedResponse = ["dcapi", { enc, cipherText }].

Interop checklist (things that commonly break)

  • Base64url encoding must be no padding.
  • CBOR must preserve byte strings as byte strings (avoid accidental UTF-8 conversions).
  • COSE_Key must be correctly constructed (key type, curve, x/y coordinates, etc.).
  • HPKE suite parameters (KEM/KDF/AEAD) must match across RP and wallet.
  • Bind nonce into the cryptographic context as required by your chosen profile.

Security guidance (Age Verification)

  • Prefer an “over age” attestation over sending date of birth.
  • Treat nonce as single-use; reject reused nonces server-side.
  • Enforce HTTPS and validate RP origin and audience binding.
  • Store as little as possible; log only opaque transaction IDs.

References

Age Verification: OpenID4VP Fallback

This page describes the OpenID4VP fallback used for age verification when the W3C Digital Credentials API is unavailable, per the EU Age Verification Profile Annex A, Section A.5.

When to use the fallback

The W3C DC API is the primary method, but:

  • Browser support is still limited
  • It may be disabled by user preference or enterprise policy
  • On mobile, production wallets (as of mid-2026) reject the openid4vp protocol identifier inside the DC API handler

When the DC API is unavailable, the Relying Party falls back to OpenID4VP via av:// deep links or QR codes.

Flow

sequenceDiagram
    participant RP as Relying Party
    participant Wallet as Age Verification App

    RP->>RP: Generate nonce, build av:// URL with DCQL query
    RP->>Wallet: av://authorize?... (deep link or QR code)
    Wallet->>Wallet: Parse request, prompt user consent
    Wallet->>RP: POST direct_post (VP Token + state)
    RP->>RP: Validate nonce, signature, expiry
    RP-->>Wallet: redirect_uri

Key Requirements (per Annex A.5)

ParameterValueSource
URL schemeav://A.5 §Custom URL Scheme
response_typevp_tokenA.5 §Response Type
response_modedirect_postA.5 §Response Mode
client_id_schemeredirect_uriA.5 §Client Identifier Scheme
Request formatPlain query params (no JAR)A.5 §Request Signing (explicitly excluded)
Query formatDCQLA.5 §DCQL Query
nonceRequiredA.5 §Nonce Parameter

Explicitly out of scope

  • JAR (signed Authorization Requests) — not required
  • JWE encrypted responses (direct_post.jwt) — TLS is sufficient
  • Trust lists of RPs — no pre-registration
  • x509_san_dns / verifier_attestation — these depend on trust lists

Example Authorization Request

av://authorize?
  response_type=vp_token
  &response_mode=direct_post
  &client_id=redirect_uri%3Ahttps%3A%2F%2Frp.example.com%2Fcallback
  &response_uri=https%3A%2F%2Frp.example.com%2Fcallback
  &nonce=n-0S6_WzA2Mj
  &dcql_query=%7B%22credentials%22%3A%5B%7B%22id%22%3A%22proof_of_age%22%2C%22format%22%3A%22mso_mdoc%22%2C%22meta%22%3A%7B%22doctype_value%22%3A%22eu.europa.ec.av.1%22%7D%2C%22claims%22%3A%5B%7B%22path%22%3A%5B%22eu.europa.ec.av.1%22%2C%22age_over_18%22%5D%7D%5D%7D%5D%7D

Decoded dcql_query:

{
  "credentials": [{
    "id": "proof_of_age",
    "format": "mso_mdoc",
    "meta": { "doctype_value": "eu.europa.ec.av.1" },
    "claims": [{ "path": ["eu.europa.ec.av.1", "age_over_18"] }]
  }]
}

Handling the Response

The wallet POSTs to response_uri with application/x-www-form-urlencoded:

FieldDescription
vp_tokenBase64url-encoded DeviceResponse (mDoc CBOR)
stateClient-managed state (if provided)

The RP extracts claims from the vp_token, validates the nonce binding, and verifies the issuer signature against the trusted CA directory.

Security

  • Nonce: Generate a fresh, cryptographically random nonce per request. Store it server-side. Reject presentations with a missing or incorrect nonce.
  • HTTPS: response_uri must use HTTPS in production.
  • Data minimization: Request only age_over_18 — avoid name, address, portrait, or other identifying attributes.

Comparison with HAIP

FeatureAge Verification (Annex A)HAIP (EUDI Wallet)
Client ID schemeredirect_urix509_san_dns, x509_hash
Signed request (JAR)Not requiredRequired
Response modedirect_postdirect_post.jwt (JWE)
Trust modelTLS + Web PKIReader Trust Store + cert validation
Target walletAge Verification AppEUDI Wallet

For full details on HAIP, see the Protocols & Formats Summary and the Relying Party Requirements.

References

Glossary

This glossary defines acronyms and terminology used across the ewQwe Age Verification project and the broader EU digital identity ecosystem.


A

ARFArchitecture and Reference Framework The EU Commission’s technical blueprint for the European Digital Identity Wallet ecosystem. Defines the architecture, roles, and interoperability requirements for all EUDI components.

Attestation A digitally-signed document issued by a trusted authority that asserts facts about a subject (e.g. “this person is over 18”). The general term used in the EU ARF for any credential or verifiable document, whether mDL, PID, or any other type.

AVIAge Verification App Instance Term used in the EU Age Verification Profile for the wallet-side component that stores and presents Proof of Age attestations. Corresponds to the wallet/ component in this project.


B

Binding Proof → see Holder Binding Proof


C

CBORConcise Binary Object Representation (RFC 7049 / RFC 8949) A binary data serialization format, similar to JSON but more compact. Used extensively in mDoc/mDL credentials. All mso_mdoc structures are CBOR-encoded.

CDDLConcise Data Definition Language (RFC 8610) A schema language for describing CBOR data structures. Used in ISO 18013-5 to formally define mDoc structures.

CIRCommission Implementing Regulation EU regulatory acts that specify technical details of the EUDI Wallet framework. CIR 2024/2977 defines the PID attribute set.

client_id In OpenID4VP, the identifier of the Relying Party (Verifier). The format depends on the client_id_scheme:

  • redirect_uri:https://rp.example.com/cb — RP identified by its redirect URI (AnnexA profile)
  • x509_san_dns:rp.example.com — RP identified by a DNS SAN in its X.509 certificate (HAIP profile)
  • x509_hash:<base64url_sha256> — RP identified by the SHA‑256 hash of its leaf X.509 certificate (HAIP profile, recommended over x509_san_dns)

cnfConfirmation Claim (RFC 7800) A JWT claim that contains the holder’s public key (usually as a jwk). Used in SD-JWT VCs to bind the credential to a specific cryptographic key, enabling holder binding.

COSECBOR Object Signing and Encryption (RFC 8152 / RFC 9052) The CBOR equivalent of JOSE (JSON Object Signing and Encryption). Defines:

  • COSE_Sign1 — single-signer signatures
  • COSE_Mac0 — message authentication codes
  • COSE_Encrypt0 / COSE_Encrypt — encryption structures

COSE_Sign1 A COSE structure for a signed object with a single signer. Used for MSO (issuer auth) and device signatures in mso_mdoc. Structure: [protected_header, unprotected_header, payload, signature].


D

DC APIDigital Credentials API A W3C browser API (navigator.credentials.get()) that enables web pages to request verifiable credentials from wallets. Replaces the earlier WebAuthn-based flow for identity credentials.

DCQLDigital Credentials Query Language A JSON-based query language used in OpenID4VP dcql_query parameter to specify which credential types and claims are requested. More expressive than Presentation Exchange (PE).

DeviceAuthentication An mDL CBOR structure that the device (holder) signs to prove session binding. Contains: ["DeviceAuthentication", SessionTranscript, DocType, DeviceNameSpacesBytes]. The signed bytes are DeviceAuthenticationBytes = Tag(24, bstr(cbor(DeviceAuthentication))).

DeviceSigned The part of an mDoc Document that contains the device-generated signature (DeviceAuth) and any device-disclosed namespaces. Proves the holder controls the credential’s device key.

Disclosure (SD-JWT) A base64url-encoded JSON array [salt, claim_name, claim_value] that reveals a single selectively-disclosed claim. The holder includes chosen Disclosures in a presentation.

docType The CBOR text string identifier for an mDoc credential type. Examples:

  • org.iso.18013.5.1.mDL — Mobile Driver’s Licence
  • eu.europa.ec.eudi.pid.1 — EU Person Identification Data
  • eu.europa.ec.eudi.ageproof.1 — EU Age Verification (AVI profile)

E

EUDI WalletEuropean Union Digital Identity Wallet The EU-standardised digital wallet app (Android/iOS) for holding and presenting European Digital Identity documents. Governed by the EU ARF.

ECElliptic Curve Cryptographic scheme used for compact key sizes and signatures. Common curves in this project:

  • P-256 (secp256r1): used for ES256 signatures, JWK thumbprints
  • P-384 (secp384r1): used for ES384 signatures

H

HAIPHigh Assurance Interoperability Profile An OpenID4VP profile requiring JARM (response_mode=direct_post.jwt) and X.509-based RP authentication (client_id_scheme=x509_hash). Provides higher security guarantees than the basic profile.

Holder The entity (person, organisation) who possesses a credential and presents it to verifiers. In this project, corresponds to the wallet / AVI.

Holder Binding Proof Proof that the holder of a credential controls the private key bound to it. This prevents credential theft — a stolen credential is useless without the binding key.

  • In mso_mdoc: the DeviceSigned block containing a COSE_Sign1 over DeviceAuthenticationBytes signed with the device private key
  • In SD-JWT VC: the Key Binding JWT (KB-JWT) signed with the key in the cnf.jwk claim

See OpenID4VP §5.3.


I

ISO 18013-5 The ISO standard defining the Mobile Driver’s Licence (mDL) data model and presentation protocol. Basis for the mso_mdoc credential format and the COSE-based signing scheme used across EU digital credentials.

Issuer A trusted authority that issues credentials (attests facts about a subject). Signs the MSO (mDL) or the JWT (SD-JWT VC).

IssuerSigned The part of an mDoc Document containing the issuer-authenticated claims and the MSO (COSE_Sign1). Enables selective disclosure — only the chosen IssuerSignedItem entries are revealed.

IssuerSignedItem A single disclosed claim in an mso_mdoc document. Contains: digestID, random (salt), elementIdentifier (claim name), elementValue (claim value). Encoded as Tag(24, bstr(cbor(IssuerSignedItem))).


J

JARMJWT Secured Authorization Response Mode An extension of OAuth 2.0 that wraps authorization responses in a JWT. In OpenID4VP, response_mode=direct_post.jwt uses JWE/JWS to encrypt/sign the VP Token response from wallet to RP. Required by the HAIP profile.

JWEJSON Web Encryption (RFC 7516) A standard for encrypting data as a JSON structure. Used in JARM to encrypt the VP Token when response_mode=direct_post.jwt.

JWKJSON Web Key (RFC 7517) A JSON representation of a cryptographic key (EC or RSA). The verifier’s JWK is used to encrypt JARM responses; its thumbprint is included in the SessionTranscript for HAIP flows.

JWK Thumbprint (RFC 7638) A SHA-256 hash of the canonical JSON representation of a JWK. Used in the OID4VPHandoverInfo to bind the session transcript to the verifier’s encryption key.

JWSJSON Web Signature (RFC 7515) A standard for signing data as a JSON structure. The basis for standard JWTs and SD-JWT VC issuer signatures.

JWTJSON Web Token (RFC 7519) A compact, URL-safe representation of claims signed (and optionally encrypted) as JSON.


K

KB-JWTKey Binding JWT The trailing JWT in an SD-JWT presentation that proves the holder controls the key in cnf.jwk. Contains aud, nonce, iat, and sd_hash. Required for holder binding in SD-JWT VC presentations.


M

mDLMobile Driver’s Licence A digital driving licence defined by ISO/IEC 18013-5. Uses the mso_mdoc credential format with docType org.iso.18013.5.1.mDL.

mDoc / mso_mdoc Short for “Mobile Document” / “Mobile Security Object + mDoc”. The credential format defined in ISO 18013-5 using CBOR encoding and COSE signing. Used for mDL, EU PID, EU Age Verification, and other digital documents.

MSOMobile Security Object The signed CBOR data structure embedded in an mDoc’s issuerAuth COSE_Sign1. Contains SHA-256 digests of each claim (rather than the values themselves), enabling selective disclosure while maintaining issuer authenticity.


N

Namespace In mDL/mDoc, claims are grouped into namespaces (CBOR text strings). Examples:

  • org.iso.18013.5.1 — Core mDL attributes (given_name, birth_date, etc.)
  • org.iso.18013.5.1.aamva — North American additions
  • eu.europa.ec.eudi.pid.1 — EU PID attributes

nonce A random value included in an Authorization Request to prevent replay attacks. Bound into the SessionTranscript (mDL) or KB-JWT (SD-JWT VC) of the presentation.


O

OID4VP / OpenID4VPOpenID for Verifiable Presentations The protocol used to request and receive Verifiable Presentations from a wallet. Builds on OAuth 2.0 authorization flows. Supports both mso_mdoc and SD-JWT VC credential formats. Key specs versions: 1.0 (current stable).

OID4VCI / OpenID4VCIOpenID for Verifiable Credential Issuance The companion protocol to OID4VP for issuing credentials into wallets.


P

PEPresentation Exchange An older query language for specifying credential requirements in OID4VP, defined by the Decentralized Identity Foundation (DIF). Being superseded by DCQL in newer profiles.

PIDPerson Identification Data The EU digital identity credential containing core identity attributes (name, birth date, nationality, etc.). Specified in CIR 2024/2977. Available in both mso_mdoc and SD-JWT VC formats.

Presentation / VP A holder-constructed object that packages one or more credentials (or selective disclosures from them) together with holder binding proof, for transmission to a verifier. In OpenID4VP the main artifact is called the VP Token.

Proof of Age (PoA) / Age Verification Attestation A credential or derived presentation that proves an age threshold (e.g. “over 18”) without revealing exact birth date. Core use case of this project.


R

RPRelying Party The service or application that requests and verifies credentials. Also called Verifier. In this project, corresponds to the webapp/ component.

response_mode OpenID4VP parameter controlling how the wallet returns the VP Token:

  • fragment — appended to redirect URI as URL fragment (same-device, cross-origin)
  • direct_post — wallet POSTs to response_uri (cross-device or CORS-friendly)
  • direct_post.jwt — wallet POSTs a JWE/JWS-wrapped VP Token (HAIP/JARM)
  • dc_api — delivered via browser DC API (no redirect needed)
  • dc_api.jwt — DC API with JARM wrapping

response_uri The URL to which the wallet POSTs the VP Token in direct_post and direct_post.jwt modes.


S

Salted Hashing The mechanism used for selective disclosure in both mDL and SD-JWT. Each claim is combined with a random salt before hashing. This prevents a verifier from guessing undisclosed claim values by brute-forcing the hash.

SANSubject Alternative Name An X.509 certificate extension field listing alternative identities for the certificate subject (DNS names, IP addresses, email). Used in x509_san_dns client_id scheme to identify RPs.

SD-JWT VCSelective Disclosure JWT for Verifiable Credentials A credential format combining standard JWTs with selective disclosure via SHA-256 commitments. Defined in IETF SD-JWT VC.

SessionTranscript An mDL CBOR structure that ties a device signature to a specific presentation session. For OID4VP flows it contains the OID4VPHandover, which commits to the client_id, nonce, response_uri, and (for HAIP) the verifier’s JWK thumbprint.

_sd The JSON array in an SD-JWT VC issuer JWT that holds base64url-encoded SHA-256 digests of the Disclosures. The holder reveals values by including the matching Disclosures in the presentation.

_sd_alg The hash algorithm used for Disclosure digests in SD-JWT VCs. Currently always sha-256.


T

Tag(24)#6.24(bstr) A CBOR semantic tag applied to a byte string to indicate that its content is CBOR-encoded. Extensively used in mDL:

  • IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem)
  • DeviceAuthenticationBytes = #6.24(bstr .cbor DeviceAuthentication)
  • DeviceNameSpacesBytes = #6.24(bstr .cbor DeviceNameSpaces)

The full Tag(24, bstr(...)) encoding must be hashed/signed, not just the inner bytes.


V

VC / Verifiable Credential A tamper-evident credential whose authorship can be cryptographically verified. The W3C Verifiable Credentials Data Model provides a general framework; mso_mdoc and SD-JWT VC are two concrete serialisations.

Verifiable Presentation / VP A holder-generated package wrapping one or more Verifiable Credentials and a binding proof. Transmitted to a verifier (RP) in response to a presentation request.

Verifier → see RP

VP Token The OpenID4VP parameter name for the value carrying the Verifiable Presentation. For mso_mdoc it is the base64url-encoded DeviceResponse; for SD-JWT VC it is the SD-JWT string.

vctVerifiable Credential Type A URI in SD-JWT VC that identifies the type/schema of the credential. E.g. https://credentials.example.com/identity_credential.


W

W3C DC API → see DC API

Wallet The software (mobile app or browser extension) that stores, manages, and presents digital credentials on behalf of the holder. In this project: the wallet-extension/ browser wallet or the EUDI Android Wallet.


X

x5chain (COSE header label 33) An unprotected COSE header containing a DER-encoded X.509 certificate chain. Used in mDL issuerAuth to carry the issuer certificate for chain verification.

x5c (JOSE header) The JSON/JWT equivalent of x5chain. An array of base64-encoded DER X.509 certificates. Used in JAR (JWT Authorization Request) to carry the RP’s certificate for x509_hash / x509_san_dns verification.


See the centralized reference list for all authoritative sources (RFCs, ISO standards, specifications).

References

This chapter centralizes external references used throughout this documentation.

EU Age Verification (Primary Use Case)

EU Digital Identity Framework (EUDI / eIDAS)

EU ARF (Architecture & Rulebooks)

EU (demo) Wallets

EU PID / mDL Regulation

W3C APIs (Browser Credential Selection)

OpenID Specifications

ISO Standards

These ISO documents are typically paywalled; the links below are the official ISO catalog entries.

IETF Drafts

IETF RFCs (Crypto / Data Formats)

DCQL (Additional Reading)

Note: In this project, DCQL is used as defined by OpenID4VP (Section 6). The link below is included as additional ecosystem context.

Browser / Platform References

Code Repositories & Reference Implementations

Playgrounds

ADB Troubleshooting Reference for Wallet Testing

A comprehensive reference of adb commands used when testing digital wallets (EUDI Wallet, France Identité, etc.) with the ewQwe Credential Verifier.

Prerequisite: adb is typically located at ~/Library/Android/sdk/platform-tools/adb on macOS (when installed via Android Studio). Add it to your PATH for convenience.


Device & Connection Management

List Connected Devices

# List all connected devices/emulators
adb devices

# List only emulator devices
adb devices -e

# List only physical (USB) devices
adb devices -d

Restart ADB Server

adb kill-server
adb start-server

Use when adb devices shows no devices even though an emulator is running or a device is connected via USB.

Connect to a Specific Device

# Use -s with device serial for single-target commands
adb -s <device_serial> install app.apk

# Use -e for emulator
adb -e install app.apk

# Use -d for physical device (USB)
adb -d install app.apk

Installing & Managing APKs

Install an APK

# Basic install
adb install /path/to/your-app.apk

# Reinstall (overwrite existing app, keeps data)
adb install -r /path/to/your-app.apk

# Install to emulator
adb -e install /path/to/your-app.apk

# Install to specific device
adb -s <device_serial> install /path/to/your-app.apk

Uninstall an App

adb uninstall <package_name>

# Example: EUDI Wallet
adb uninstall eu.europa.ec.euidiw

# Example: France Identité
adb uninstall fr.gouv.interieur.franceidentite

List Installed Packages

# List all packages
adb shell pm list packages

# Filter by keyword
adb shell pm list packages | grep -i eudi
adb shell pm list packages | grep -i france
adb shell pm list packages | grep -i identite

W3C Digital Credentials API — Provider Registration

Check Registered Credential Providers

# List all credential providers registered with Android CredentialManager
adb shell dumpsys credential

Interpretation:

  • Empty/missing output → No wallet has registered as a CredentialProviderService with Android’s CredentialManager on this device.
  • Populated output → Wallets that implement a CredentialProviderService will appear here. Look for entries under credential-services.

Important caveat: Some wallets (including the EUDI Wallet) implement Annex C Sub-protocol B using an Activity intent filter on their MainActivity (for actions androidx.credentials.registry.provider.action.GET_CREDENTIAL / androidx.identitycredentials.action.GET_CREDENTIALS) instead of a CredentialProviderService. This approach does NOT appear in dumpsys credential.

Unfortunately, this Activity-based approach does not work with Chrome on Android 14+. Chrome delegates to Android’s CredentialManager, which only dispatches to CredentialProviderService implementations — not to Activities with intent filters. The DC API request is never received by the wallet.

See eudi_wallet_dc_api_analysis.md for the full analysis.


DNS & Network Configuration

Map demo.ewqwe.local to Host Machine (Emulator)

For the Android emulator, the host machine is reachable at 10.0.2.2. Map the demo domain inside the emulator:

adb root && adb shell "echo '10.0.2.2  demo.ewqwe.local' >> /etc/hosts"

⚠️ Requires adb root (only works on emulator or rooted devices). The emulator must be started with -writable-system.

Verify the Hosts File

adb shell cat /etc/hosts

Verify Network Connectivity from Emulator

# Ping your host machine from the emulator
adb shell ping 10.0.2.2

# Test DNS resolution inside the emulator
adb shell ping demo.ewqwe.local

Transferring Files (CA Certificates, APKs, etc.)

Push Files to Device

# Push a CA certificate to Downloads
adb push /path/to/ewqwe-ca.crt /sdcard/Downloads/ewqwe-ca.crt

Push CA Certificate to System Trust Store (Rooted Emulator)

adb root
adb remount
adb push /path/to/ewqwe-ca.crt /system/etc/security/cacerts/ewqwe-ca.crt
adb reboot

Pull Files from Device

adb pull /sdcard/Download/some-file.txt /local/path/

Viewing Wallet Logs (Logcat)

Filter by Process ID (EUDI Wallet)

# Stream logs for the EUDI Wallet process only
adb logcat --pid=$(adb shell pidof eu.europa.ec.euidiw)

Filter by Log Tags

# Show only specific wallet log tags
adb logcat -s "OpenId4VpManager" "PresentationManager" "WalletCore"

France Identité: Substitute the package name or tags used by France Identité.

Grep for Wallet Keywords

adb logcat | grep -iE "eudi|openid4vp|presentation|mdoc|wallet"
adb logcat | grep -iE "france|identite|annex_b|dcapi"

Clear Logcat

adb logcat -c

System Information & Debugging

Check Android Version

adb shell getprop ro.build.version.release

Check if WebView Supports W3C Digital Credentials

# Check Chrome version on device
adb shell dumpsys package com.android.chrome | grep versionName

Grant Permissions to a Wallet App

adb shell pm grant <package_name> android.permission.BLUETOOTH
adb shell pm grant <package_name> android.permission.BLUETOOTH_ADMIN
# Open an openid4vp deep link on the device
adb shell am start -d "openid4vp://?client_id=..." -a android.intent.action.VIEW

# Open a generic URL
adb shell am start -d "https://example.com" -a android.intent.action.VIEW

Force-Stop and Clear App Data

adb shell am force-stop <package_name>
adb shell pm clear <package_name>

Reboot the Emulator/Device

adb reboot

Testing Certificates

Inspect Certificate Chain

openssl x509 -in your_fullchain.pem -noout -issuer
openssl x509 -in your_cert.pem -noout -subject -issuer

Download Root CA Certificates (e.g., Let’s Encrypt)

curl -s https://letsencrypt.org/certs/isrgrootx1.pem -o isrg_root_x1.pem
curl -s https://letsencrypt.org/certs/isrg-root-x2.pem -o isrg_root_x2.pem

Android Emulator-Specific Commands

Cold Boot

Use when the emulator is unresponsive or has stale state. In Android Studio: Device Manager → Right-click device → Cold Boot Now

Enable Hardware Keyboard

In Android Studio emulator settings, enable “Hardware keyboard present” to use your computer’s keyboard.

Grant Superuser (root)

adb root

Only works on emulator images or rooted devices. Required for modifying /etc/hosts or system-level CA certificates.

Remount System Partition as Writable

adb remount

Required after adb root to push files to system partitions like /system/etc/security/cacerts/.


Quick Reference by Use Case

What You NeedCommand
See connected devicesadb devices
Install an APKadb install /path/to/app.apk
Force reinstalladb install -r /path/to/app.apk
Check W3C credential providersadb shell dumpsys credential
Map demo domain in emulatoradb root && adb shell "echo '10.0.2.2 demo.ewqwe.local' >> /etc/hosts"
Push CA cert to Downloadsadb push cert.crt /sdcard/Downloads/
Push CA cert to system storeadb root && adb remount && adb push cert.crt /system/etc/security/cacerts/
Stream wallet logsadb logcat --pid=$(adb shell pidof <package>)
Filter logcat by tagsadb logcat -s "Tag1" "Tag2"
Search logs for keywordsadb logcat | grep -iE "eudi|openid4vp"
Restart ADB serveradb kill-server && adb start-server
Check Android versionadb shell getprop ro.build.version.release
List installed packagesadb shell pm list packages | grep -i <keyword>
Open a deep linkadb shell am start -d "<url>" -a android.intent.action.VIEW
Force-stop an appadb shell am force-stop <package>
Clear app dataadb shell pm clear <package>
Reboot deviceadb reboot
Root the emulatoradb root
Remount system as writableadb remount

References