Skip to content

How to configure the application

Table of contents

General configuration

All core network and trust settings are centralized in the WalletCoreConfig interface inside the core-logic module:

interface WalletCoreConfig {
    // 1. Issuing API
    val vciConfig: List<OpenId4VciManager.Config>

    // 2. Wallet Provider Host
    val walletProviderHost: String

    // 3. Trusted certificates / EudiWallet configuration
    val config: EudiWalletConfig

    // 4. Whether document keys require Android user authentication before creation or use
    val userAuthenticationRequired: Boolean

    // 5. Predefined set of document categories and their associated identifiers
    val documentCategories: DocumentCategories

    // 6. Interval at which revocations are checked
    val revocationInterval: Duration

    // 7. Document issuance rules (default rule + per-document overrides)
    val documentIssuanceConfig: DocumentIssuanceConfig

    // 8. Passport scanning issuer (separate endpoint for age verification documents)
    val passportScanningIssuerConfig: OpenId4VciManager.Config?

    // 9. Face match SDK model sources and thresholds
    val faceMatchConfig: FaceMatchConfig
}

You configure these properties per flavor by providing a WalletCoreConfigImpl for each build variant:

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

Each flavor can use different issuer URLs, wallet provider hosts, and trust stores.

  1. Issuing API

The Issuing API is configured via the vciConfig property:

```kotlin
override val vciConfig: List<OpenId4VciManager.Config>
    get() = listOf(
       OpenId4VciManager.Config.Builder()
      .withIssuerUrl(issuerUrl = "https://test.issuer.dev.ageverification.dev")
      .withClientAuthenticationType(OpenId4VciManager.ClientAuthenticationType.AttestationBased)
      .withAuthFlowRedirectionURI(BuildConfig.ISSUE_AUTHORIZATION_DEEPLINK)
      .withParUsage(OpenId4VciManager.Config.ParUsage.IF_SUPPORTED)
      .withDPoPUsage(OpenId4VciManager.Config.DPoPUsage.IfSupported())
      .build()
)
```

Adjust the configuration per flavor in the corresponding WalletCoreConfigImpl.

  1. Wallet Provider Host

The Wallet Provider Host is configured via the walletProviderHost property:

```kotlin
override val walletProviderHost: String
    get() = "https://wallet-provider.ageverification.dev"
```

Again, set a different value per flavor in the corresponding WalletCoreConfigImpl.

  1. Trusted certificates

Trusted certificates are configured via the config property:

```kotlin
_config = EudiWalletConfig {
   configureReaderTrustStore(context, R.raw.av_issuer_ca01)
}
```

The application's IACA certificates are located here

Configure EudiWalletConfig per flavor inside the appropriate WalletCoreConfigImpl.

  1. Preregistered Client Scheme

If you plan to use the ClientIdScheme.Preregistered for OpenId4VP configuration, please add the following to the configuration files.

```kotlin
const val OPENID4VP_VERIFIER_API_URI = "your_verifier_url"
const val OPENID4VP_VERIFIER_LEGAL_NAME = "your_verifier_legal_name"
const val OPENID4VP_VERIFIER_CLIENT_ID = "your_verifier_client_id"

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

According to the specifications, issuance, presentation require deep-linking for the same device flows.

If you want to adjust any schema, you can alter the AndroidLibraryConventionPlugin inside the build-logic module.

val walletScheme = "av"
val walletHost = "*"

val eudiOpenId4VpScheme = "eudi-openid4vp"
val eudiOpenid4VpHost = "authorize"

val mdocOpenId4VpScheme = "mdoc-openid4vp"
val mdocOpenid4VpHost = "authorize"

val openId4VpScheme = "openid4vp"
val openid4VpHost = "authorize"

val avspScheme = "avsp"
val avspHost = "present"

val avScheme = "av"
val avHost = "*"

val credentialOfferScheme = "openid-credential-offer"
val credentialOfferHost = "credential_offer"

val openId4VciAuthorizationScheme = "eu.europa.ec.av"
val openId4VciAuthorizationHost = "authorization"

Let's assume you want to change the credential offer schema to custom-my-offer:// the AndroidLibraryConventionPlugin should look like this:

val walletScheme = "av"
val walletHost = "*"

val eudiOpenId4VpScheme = "eudi-openid4vp"
val eudiOpenid4VpHost = "authorize"

val mdocOpenId4VpScheme = "mdoc-openid4vp"
val mdocOpenid4VpHost = "authorize"

val openId4VpScheme = "openid4vp"
val openid4VpHost = "authorize"

val avspScheme = "avsp"
val avspHost = "present"

val avScheme = "av"
val avHost = "*"

val credentialOfferScheme = "custom-my-offer"
val credentialOfferHost = "credential_offer"

val openId4VciAuthorizationScheme = "eu.europa.ec.av"
val openId4VciAuthorizationHost = "authorization"

In case of an additive change, e.g., adding an extra credential offer schema, you must adjust the following.

AndroidLibraryConventionPlugin:

val credentialOfferScheme = "openid-credential-offer"
val credentialOfferHost = "credential_offer"

val myOwnCredentialOfferScheme = "custom-my-offer"
val myOwnCredentialOfferHost = "*"
// Manifest placeholders used for OpenId4VCI
manifestPlaceholders["credentialOfferHost"] = credentialOfferHost
manifestPlaceholders["credentialOfferScheme"] = credentialOfferScheme
manifestPlaceholders["myOwnCredentialOfferHost"] = myOwnCredentialOfferHost
manifestPlaceholders["myOwnCredentialOfferScheme"] = myOwnCredentialOfferScheme
addConfigField("CREDENTIAL_OFFER_SCHEME", credentialOfferScheme)
addConfigField("MY_OWN_CREDENTIAL_OFFER_SCHEME", myOwnCredentialOfferScheme)

Android Manifest (inside assembly-logic module):

<intent-filter>
    <action android:name="android.intent.action.VIEW" />

    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />

    <data
        android:host="${credentialOfferHost}"
        android:scheme="${credentialOfferScheme}" />

</intent-filter>

<intent-filter>
    <action android:name="android.intent.action.VIEW" />

    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />

    <data
        android:host="${myOwnCredentialOfferHost}"
        android:scheme="${myOwnCredentialOfferScheme}" />

</intent-filter>

DeepLinkType (DeepLinkHelper Object inside ui-logic module):

enum class DeepLinkType(val schemas: List<String>, val host: String? = null) {

    OPENID4VP(
        schemas = listOf(
            BuildConfig.OPENID4VP_SCHEME,
            BuildConfig.EUDI_OPENID4VP_SCHEME,
            BuildConfig.MDOC_OPENID4VP_SCHEME,
            BuildConfig.AVSP_SCHEME,
            BuildConfig.AV_SCHEME
        )
    ),
    CREDENTIAL_OFFER(
        schemas = listOf(
            BuildConfig.CREDENTIAL_OFFER_SCHEME,
            BuildConfig.MY_OWN_CREDENTIAL_OFFER_SCHEME
        )
    ),
    ISSUANCE(
        schemas = listOf(BuildConfig.ISSUE_AUTHORIZATION_SCHEME),
        host = BuildConfig.ISSUE_AUTHORIZATION_HOST
    ),
    EXTERNAL(
        emptyList()
    ),
    DYNAMIC_PRESENTATION(
        emptyList()
    );
}

In the case of an additive change regarding OpenID4VP, you also need to update the EudiWalletConfig for each flavor inside the core-logic module.

configureOpenId4Vp {
   withSchemes(
      listOf(
         BuildConfig.OPENID4VP_SCHEME,
         BuildConfig.EUDI_OPENID4VP_SCHEME,
         BuildConfig.MDOC_OPENID4VP_SCHEME,
         BuildConfig.AVSP_SCHEME,
         BuildConfig.AV_SCHEME,
         BuildConfig.YOUR_OWN_OPENID4VP_SCHEME
      )
   )
}

Scoped Issuance Document Configuration

The credential configuration is derived directly from the issuer's metadata. The issuer URL is configured per flavor via the vciConfig property inside the core-logic module at src/demo/config/WalletCoreConfigImpl and src/dev/config/WalletCoreConfigImpl. If you want to add or adjust the displayed scoped documents, you must modify the issuer's metadata, and the wallet will automatically resolve your changes.

Passport Scanning Issuer Configuration

The application uses a dedicated passportScanningIssuerConfig property (declared on WalletCoreConfig) to issue age verification documents after a passport scan. This is a separate OpenId4VciManager.Config from the regular vciConfig entry — it is not the second element of the vciConfig list.

The configuration is flavor-specific and defined in core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl and core-logic/src/dev/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.

Example (Dev / Demo flavor):

/**
 * Configuration for the passport scanning issuer.
 */
override val passportScanningIssuerConfig: OpenId4VciManager.Config =
    OpenId4VciManager.Config.Builder()
        .withIssuerUrl(issuerUrl = "https://passport.issuer.dev.ageverification.dev")
        .withClientAuthenticationType(OpenId4VciManager.ClientAuthenticationType.AttestationBased)
        .withAuthFlowRedirectionURI(BuildConfig.ISSUE_AUTHORIZATION_DEEPLINK)
        .withParUsage(OpenId4VciManager.Config.ParUsage.NEVER)
        .withDPoPUsage(OpenId4VciManager.Config.DPoPUsage.Disabled)
        .build()

The passport scanning issuer configuration is optional. If the vciConfig list contains only one issuer, passport scanning issuance will not be available.

Important Note: PAR (Pushed Authorization Request) and DPoP (Demonstration of Proof-of-Possession) are not supported in the AV profile. Both features must be disabled by setting:

  • parUsage = OpenId4VciManager.Config.ParUsage.NEVER
  • useDPoPIfSupported = false

Face Match Configuration

The application uses AI models for face liveness detection and face matching during passport verification flows. These models are configured via the faceMatchConfig property in the WalletCoreConfig interface.

The configuration is flavor-specific and defined in core-logic/src/demo/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl and core-logic/src/dev/java/eu/europa/ec/corelogic/config/WalletCoreConfigImpl.

Configuration Structure:

Model sources are modeled by the FaceMatchModelSource sealed interface — a model is either an asset bundled in the APK or a remote URL that is verified against a pinned SHA-256 digest. A configuration cannot supply a remote URL without also pinning its hash; downloads whose hash does not match are discarded.

sealed interface FaceMatchModelSource {
    /** Model shipped inside the APK as an asset file. */
    data class Asset(val filename: String) : FaceMatchModelSource

    /**
     * Model fetched from [url] (must be https) and verified against [sha256Hex]
     * (case-insensitive hex digest). Downloads whose hash does not match are discarded.
     */
    data class Remote(val url: String, val sha256Hex: String) : FaceMatchModelSource
}

data class FaceMatchConfig(
    val faceDetectorModel: FaceMatchModelSource,
    val embeddingExtractorModel: FaceMatchModelSource,
    val livenessModel0: FaceMatchModelSource,
    val livenessModel1: FaceMatchModelSource,
    val livenessThreshold: Double,        // Threshold for liveness detection (0.0-1.0)
    /** Minimum cosine-similarity score to accept as the same subject. Recommended to be >= 0.8. */
    val matchingThreshold: Double,        // Threshold for face matching (0.0-1.0)
)

Example Configuration (Dev / Demo flavor):

override val faceMatchConfig: FaceMatchConfig = FaceMatchConfig(
    faceDetectorModel = FaceMatchModelSource.Asset("mediapipe_long.onnx"),
    embeddingExtractorModel = FaceMatchModelSource.Remote(
        url = "https://github.com/eu-digital-identity-wallet/av-app-android-wallet-ui/releases/download/2025.10-2/glintr100.onnx",
        sha256Hex = "a7933ea5330113b01c9b60351d8f4c33003f145d8470ac5f0e52ee2effe25c60",
    ),
    livenessModel0 = FaceMatchModelSource.Asset("silentface40.onnx"),
    livenessModel1 = FaceMatchModelSource.Asset("silentface27.onnx"),
    livenessThreshold = 0.972017,
    matchingThreshold = 0.5,
)

Model Source Options:

The model sources can be specified as:

  • FaceMatchModelSource.Asset(filename) — model bundled in the APK assets
  • FaceMatchModelSource.Remote(url, sha256Hex) — model downloaded over HTTPS at runtime and verified against the pinned SHA-256 digest

Threshold Configuration:

  • livenessThreshold: Controls the sensitivity of liveness detection. Higher values (e.g., 0.9) are more strict and may reject more legitimate faces, while lower values (e.g., 0.7) are more lenient but may accept more spoofing attempts.
  • matchingThreshold: Controls the sensitivity of face matching between the passport photo and selfie. Higher values require a closer match, while lower values are more forgiving of lighting and angle differences.

⚠️ SECURITY WARNING:

The models referenced in the default configuration are currently hosted on GitHub Releases for development and testing purposes only. This is NOT recommended for production environments. Remote model sources must always be paired with a pinned sha256Hex so a tampered or misissued download is rejected.

Batch Document Issuance Configuration

The app is configured to use batch document issuance by default, requesting a batch of credentials at once and handling them according to a defined policy.

You can configure the following aspects of batch document issuance in DocumentIssuanceRule:

  1. number of credentials (formerly batch size) - The number of credentials to be issued for the document at once
  2. Credential policy - whether to use each credential once or rotate through them

These settings are configured in your flavor's implementation of WalletCoreConfigImpl. For example, in the demo flavor:

internal class WalletCoreConfigImpl(
    private val context: Context
) : WalletCoreConfig {

    // ...other configuration...

    val documentIssuanceConfig: DocumentIssuanceConfig
        get() = DocumentIssuanceConfig(
            defaultRule = DocumentIssuanceRule(
                policy = CredentialPolicy.OneTimeUse,
                numberOfCredentials = 30
            ),
            documentSpecificRules = mapOf()
        )
}

Note that the batch size will be limited by the issuer's metadata configuration, so you may not be able to request a batch larger than what the issuer allows. To understand the issuer's configuration, you can check the issuer's metadata endpoint, which is usually available at https://<issuer-url>/.well-known/openid-configuration. Specifically, look for the credential_batch_size field in the metadata response.

Note that the batch size will be limited by the issuer's metadata configuration, so you may not be able to request a batch larger than what the issuer allows. to understand the issuer's configuration, you can check the issuer's metadata endpoint, which is usually available at https://<issuer-url>/.well-known/openid-configuration. specifically, look for the credential_batch_size field in the metadata response.

Theme configuration

The application allows the configuration of:

  1. Colors
  2. Images
  3. Shape
  4. Fonts
  5. Dimension

Via ThemeManager.Builder().

Pin Storage configuration

The application allows the configuration of the PIN storage. You can configure the following:

  1. Where the pin will be stored
  2. From where the pin will be retrieved
  3. Pin matching and validity

Via the StorageConfig inside the authentication-logic module.

interface StorageConfig {
    val pinStorageProvider: PinStorageProvider
    val biometryStorageProvider: BiometryStorageProvider
}

You can provide your storage implementation by implementing the PinStorageProvider interface and then setting it as the default to the StorageConfigImpl pinStorageProvider variable. The project utilizes Koin for Dependency Injection (DI), thus requiring adjustment of the LogicAuthenticationModule graph to provide the configuration.

The PinStorageProvider interface:

interface PinStorageProvider {
    fun hasPin(): Boolean
    fun setPin(pin: String)
    fun isPinValid(pin: String): Boolean

    fun getFailedAttempts(): Int
    fun incrementFailedAttempts(): Int
    fun resetFailedAttempts()

    fun setLockoutForDuration(durationMillis: Long)
    fun getLockoutUntil(): Long
    fun isCurrentlyLockedOut(): Boolean
}

Implementation Example:

The default implementation is PinStorageProviderImpl (in the authentication-logic module). It does not store a raw PIN hash. Instead it derives a per-PIN key with Argon2 (Argon2KeyDerivation), uses that key to AES-GCM-encrypt a random 32-byte vault key, and persists the wrapped vault key plus the Argon2 parameters (m, t, parallelism) and salt in an AuthMetadata record. On validation the PIN is re-derived through Argon2, the vault key is unwrapped with AES-GCM (which doubles as an authenticity check), and the vault key is handed to the VaultKeyProvider on success. PIN material and derived keys are zeroed after use.

class PinStorageProviderImpl(
    private val authMetadataStore: AuthMetadataStore,
    private val clock: ElapsedRealtimeClock,
    private val bootIdProvider: BootIdProvider,
    private val vaultKeyProvider: VaultKeyProvider,
) : PinStorageProvider {

    override fun setPin(pin: String) {
        val pinChars = pin.toCharArray()
        try {
            val pinSalt = ByteArray(Argon2KeyDerivation.SALT_LEN)
                .also { SecureRandom().nextBytes(it) }
            val kPin = Argon2KeyDerivation.derive(pinChars, pinSalt)

            // ...create or rotate a 32-byte vault key, wrap it with kPin via AES-GCM...
            val (wrappedVaultIv, wrappedVault) = aesGcmEncrypt(kPin, kVault, pinSalt)
            kPin.fill(0)

            runBlocking {
                authMetadataStore.write(
                    AuthMetadata(
                        version = 0x01,
                        kdfAlgo = KDF_ALGO,
                        kdfM = Argon2KeyDerivation.M_COST_KIB,
                        kdfT = Argon2KeyDerivation.T_COST,
                        kdfP = Argon2KeyDerivation.PARALLELISM,
                        pinSalt = pinSalt,
                        wrappedVaultIv = wrappedVaultIv,
                        wrappedVault = wrappedVault,
                        bootId = bootIdProvider.currentBootId(),
                    )
                )
            }
        } finally {
            pinChars.fill('\u0000')
        }
    }

    override fun isPinValid(pin: String): Boolean {
        val meta = readWithReanchor() ?: return false
        if (isLockedOutByMeta(meta)) return false
        val pinChars = pin.toCharArray()
        return try {
            val kPin = Argon2KeyDerivation.derive(
                pin = pinChars, salt = meta.pinSalt,
                mCostKib = meta.kdfM, tCost = meta.kdfT, parallelism = meta.kdfP,
            )
            val kVault = try {
                aesGcmDecrypt(kPin, meta.wrappedVault, meta.wrappedVaultIv, meta.pinSalt)
            } catch (_: Exception) {
                null
            } finally {
                kPin.fill(0)
            }
            if (kVault != null) {
                vaultKeyProvider.unlock(kVault)
                kVault.fill(0)
                true
            } else {
                false
            }
        } finally {
            pinChars.fill('\u0000')
        }
    }

    // ...failed-attempt counters and reboot-aware lockout (see setLockoutForDuration /
    //    getLockoutUntil / isCurrentlyLockedOut)...
}

The default implementation uses Argon2 for PIN-key derivation with a random salt, AES-GCM to wrap a random vault key (which doubles as an authenticity check on validation), zeroing of sensitive byte arrays after use, and reboot-aware lockout via ElapsedRealtimeClock and BootIdProvider.

⚠️ Note: PBKDF2 is intentionally not used in this project. A checkNoPbkdf2 Gradle task (build.gradle.kts) fails the build if the literal PBKDF2 appears in authentication-logic. Do not reintroduce PBKDF2-based PIN hashing.

Config Example:

class StorageConfigImpl(
    private val pinImpl: PinStorageProvider,
    private val biometryImpl: BiometryStorageProvider
) : StorageConfig {
    override val pinStorageProvider: PinStorageProvider
        get() = pinImpl
    override val biometryStorageProvider: BiometryStorageProvider
        get() = biometryImpl
}

Config Construction via Koin DI Example:

@Single
fun provideStorageConfig(
    authMetadataStore: AuthMetadataStore,
    clock: ElapsedRealtimeClock,
    bootIdProvider: BootIdProvider,
    vaultKeyProvider: VaultKeyProvider,
    biometryStorageProvider: BiometryStorageProvider,
): StorageConfig = StorageConfigImpl(
    pinImpl = PinStorageProviderImpl(authMetadataStore, clock, bootIdProvider, vaultKeyProvider),
    biometryImpl = biometryStorageProvider
)

Analytics configuration

The application allows the configuration of multiple analytics providers. You can configure the following:

  1. Initializing the provider (e.g., Firebase, Appcenter, etc)
  2. Screen logging
  3. Event logging

Via the AnalyticsConfig inside the analytics-logic module.

interface AnalyticsConfig {
    val analyticsProviders: Map<String, AnalyticsProvider>
        get() = emptyMap()
}

You can provide your implementation by implementing the AnalyticsProvider interface and then adding it to your AnalyticsConfigImpl analyticsProviders variable. You will also need the provider's token/key, thus requiring a Map configuration. The project utilizes Koin for Dependency Injection (DI), thus requiring adjustment of the LogicAnalyticsModule graph to provide the configuration.

Implementation Example:

object AppCenterAnalyticsProvider : AnalyticsProvider {
    override fun initialize(context: Application, key: String) {
        AppCenter.start(
            context,
            key,
            Analytics::class.java
        )
    }

    override fun logScreen(name: String, arguments: Map<String, String>) {
        logEvent(name, arguments)
    }

    override fun logEvent(event: String, arguments: Map<String, String>) {
        if (Analytics.isEnabled().get()) {
            Analytics.trackEvent(event, arguments)
        }
    }
}

Config Example:

class AnalyticsConfigImpl : AnalyticsConfig {
    override val analyticsProviders: Map<String, AnalyticsProvider>
        get() = mapOf("YOUR_OWN_KEY" to AppCenterAnalyticsProvider)
}

Config Construction via Koin DI Example:

@Single
fun provideAnalyticsConfig(): AnalyticsConfig = AnalyticsConfigImpl()