Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* Use VC-K data classes to (de-)serialize received/emitted data
* Add support for iOS using ISO/IEC 18013-7 Annex C protocol
* Add support for issuance via the DC API based on the preliminary spec defined in https://github.com/openid/OpenID4VCI/pull/476
* Add `TrustListService` for loading LoTE regularly in the background
* Display trust evaluation in Consent Screen and in `CredentialsDetailsView`

# Release 5.7.6
* Update to VC-K 5.12.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package at.asitplus.wallet.app.common

import at.asitplus.KmmResult
import at.asitplus.catching
import at.asitplus.etsi.ListOfTrustedEntities
import at.asitplus.etsi.TrustListPayload
import at.asitplus.signum.indispensable.josef.JwsCompact
import at.asitplus.signum.indispensable.pki.X509Certificate
import at.asitplus.wallet.lib.agent.SubjectCredentialStore
import at.asitplus.wallet.lib.etsi.LoTEFilterCriteria
import at.asitplus.wallet.lib.etsi.LoTEFilterService
import at.asitplus.wallet.lib.etsi.isTrustedBy
import at.asitplus.wallet.lib.jws.VerifyJwsObjectFun
import at.asitplus.wallet.lib.jws.VerifyJwsObjectJades
import data.storage.PersistentTrustListStore
import io.github.aakira.napier.Napier
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable.isActive
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import ui.composables.TrustState
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds


val asitRootPem = "-----BEGIN CERTIFICATE-----\n" +
"MIICNzCCAd6gAwIBAgIUVKbs5o5e1jnILQPrKrsBnZbJj5EwCgYIKoZIzj0EAwIw\n" +
"MTELMAkGA1UEBhMCQVQxDjAMBgNVBAoMBUEtU0lUMRIwEAYDVQQDDAlJQUNBIDIw\n" +
"MjYwHhcNMjYwNDE2MTQ1NDQ1WhcNMjcwNDE2MTQ1NDQ1WjAxMQswCQYDVQQGEwJB\n" +
"VDEOMAwGA1UECgwFQS1TSVQxEjAQBgNVBAMMCUlBQ0EgMjAyNjBZMBMGByqGSM49\n" +
"AgEGCCqGSM49AwEHA0IABA7215fpBuEqE0AmnwgUoKMGCIZjnXMPZohMJKKrO0f/\n" +
"84eg4bFLVUAM25Clukqbjr/Ol3Pa16LLhxQoSIupJx+jgdMwgdAwEgYDVR0TAQH/\n" +
"BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAQYwMQYDVR0fBCowKDAmoCSgIoYgaHR0\n" +
"cDovL3dhbGxldC5hLXNpdC5hdC9jcmwvMS5jcmwwIgYDVR0SBBswGYYXaHR0cHM6\n" +
"Ly93YWxsZXQuYS1zaXQuYXQwEwYDVR0gBAwwCjAIBgYEAI96AQEwHwYDVR0jBBgw\n" +
"FoAUTXNbbT6FjuThGuNsHM5KMNSead4wHQYDVR0OBBYEFE1zW20+hY7k4RrjbBzO\n" +
"SjDUnmneMAoGCCqGSM49BAMCA0cAMEQCIDMQ328z1NWGUK6wcLC8JmgTkKxt3Ycw\n" +
"BapSKA9Qxhd6AiANUlRcM5BT5JKZL3yNSvUlERYXqcEYs50sxwE60SVkEw==\n" +
"-----END CERTIFICATE-----\n"

class TrustListService(
private val persistentTrustListStore: PersistentTrustListStore,
httpService: HttpService,
private val verifyJwsObject: VerifyJwsObjectFun = VerifyJwsObjectJades(),
) {
private var job: Job? = null
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val client = httpService.buildHttpClient()
// A-SIT trust list
val aistIssuerCert = X509Certificate.decodeFromPem(asitRootPem).getOrThrow()
private val loTeFilterService: LoTEFilterService = LoTEFilterService()


private val defaultUrls = listOf(
"https://acceptance.trust.tech.ec.europa.eu/lists/eudiw/pid-providers.json",
"https://acceptance.trust.tech.ec.europa.eu/lists/eudiw/wallet-providers.json",
"https://acceptance.trust.tech.ec.europa.eu/lists/eudiw/wrpac-providers.json",
"https://acceptance.trust.tech.ec.europa.eu/lists/eudiw/mdl-providers.json",
"https://trust.tech.ec.europa.eu/lists/eudiw/pub-eaa-providers.json"
)

fun observeTrustStateForEntry(
storeEntryFlow: Flow<SubjectCredentialStore.StoreEntry?>
): Flow<TrustState> {
return combine(
storeEntryFlow,
persistentTrustListStore.observeTrustContainer()
) { entry, trustContainerMap ->
if (entry == null) return@combine TrustState.EVALUATING

val issuerBytes = entry.issuer ?: return@combine TrustState.UNKNOWN
val allLoTes = trustContainerMap.values.toList()
val serviceType = entry.schemaUri

evaluateIssuer(issuerBytes, allLoTes, serviceType)
}
}

/**
* Evaluates if a given issuer is trusted based on the internal root cert and LoTEs.
*/
fun evaluateIssuer(
issuerBytes: ByteArray,
trustLists: List<ListOfTrustedEntities>,
serviceType: String
): TrustState {
return try {
val certificate = X509Certificate.decodeFromDer(issuerBytes)

if (certificate.isTrustedBy(listOf(aistIssuerCert)).isSuccess) {
return TrustState.TRUSTED
}

val criteria = LoTEFilterCriteria(expectedServiceType = serviceType)
val certificateList: List<X509Certificate> = trustLists
.flatMap { lote -> loTeFilterService.extractTrustedCertificates(lote, criteria) }
.mapNotNull { it.certificate }

if (certificateList.isEmpty()) {
return TrustState.UNTRUSTED
}

val validationResult = certificate.isTrustedBy(certificateList)

if (validationResult.isSuccess) TrustState.TRUSTED else TrustState.UNTRUSTED
} catch (_: Exception) {
TrustState.UNTRUSTED
}
}

/**
* Starts the periodic background loop.
* Default interval is 1 hour as configured.
*/
fun startChecking(interval: Duration = 1.hours) {
job?.cancel()

job = scope.launch {
delay(5.seconds)
while (isActive) {
refreshAll()
delay(interval)
}
}
}

fun refreshAll(): Job = scope.launch {
defaultUrls.forEach { url ->
syncSingleUrl(url)
}
}

private suspend fun syncSingleUrl(url: String) {
fetchTrustList(url)
.onSuccess { result ->
persistentTrustListStore.persistTrustList(url, result.rawJwsText)
Napier.i("Successfully synced and persisted Trust List: $url")
}
.onFailure { e ->
Napier.e("Background sync failed for Trust List: $url", e)
}
}

/**
* Fetches the signed List of Trusted Entities (LoTE)
* Returns a [KmmResult] wrapping a [TrustListResult] containing both raw and parsed data.
*/
suspend fun fetchTrustList(url: String): KmmResult<TrustListResult> = catching {
Napier.i("Fetching Trust List from: $url")
val response = client.get(url) {
accept(ContentType.Application.Json)
}

val responseBody = response.bodyAsText()

val jws = JwsCompact.parse<TrustListPayload>(responseBody).getOrThrow()

verifyJwsObject(jws.first).getOrThrow()

Napier.i("Successfully validated Trust List signature from $url")

TrustListResult(
rawJwsText = responseBody,
loTe = jws.second.loTe
)
}
}

/**
* Data container wrapping both the raw string representation for persistent caching
* and the verified domain object.
*/
data class TrustListResult(
val rawJwsText: String,
val loTe: ListOfTrustedEntities
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package at.asitplus.wallet.app.common
import at.asitplus.wallet.lib.agent.Validator
import data.storage.DataStoreService
import data.storage.PersistentSubjectCredentialStore
import data.storage.PersistentTrustListStore
import io.github.aakira.napier.Antilog
import io.github.aakira.napier.Napier
import org.multipaz.prompt.PromptModel
Expand All @@ -13,6 +14,8 @@ data class WalletDependencyProvider(
val platformAdapter: PlatformAdapter,
var subjectCredentialStore: PersistentSubjectCredentialStore =
PersistentSubjectCredentialStore(dataStoreService, Validator()),
var trustListStore: PersistentTrustListStore =
PersistentTrustListStore(dataStoreService),
val buildContext: BuildContext,
val promptModel: PromptModel,
val antilog: Antilog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import at.asitplus.wallet.lib.agent.SubjectCredentialStore
import at.asitplus.wallet.lib.agent.Validator
import at.asitplus.wallet.lib.ktor.openid.CredentialIdentifierInfo
import data.storage.DataStoreService
import data.storage.PersistentTrustListStore
import data.storage.WalletSubjectCredentialStore
import io.github.aakira.napier.Napier
import io.ktor.client.call.*
Expand Down Expand Up @@ -62,6 +63,7 @@ class WalletMain(
val credentialValidityService: CredentialValidityService,
val attestationService: AttestationService,
sessionCoroutineScope: CoroutineScope,
val trustListService: TrustListService,
) {
val appReady = MutableStateFlow<Boolean?>(null)

Expand All @@ -79,6 +81,7 @@ class WalletMain(

init {
credentialValidityService.startChecking()
trustListService.startChecking()
if (keyMaterial.keyMaterial is FallBackKeyMaterial) {
Napier.e("FallBackKeyMaterial: ${keyMaterial.keyMaterial.reason}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import at.asitplus.wallet.app.common.ErrorService
import at.asitplus.wallet.app.common.LoadingStatusService
import at.asitplus.wallet.app.common.RealCapabilitiesService
import at.asitplus.wallet.app.common.SESSION_NAME
import at.asitplus.wallet.app.common.TrustListService
import at.asitplus.wallet.app.common.WalletMain
import at.asitplus.wallet.app.common.presentation.LocalPresentmentSessionCoordinator
import at.asitplus.wallet.app.common.data.di.dataModule
import at.asitplus.wallet.app.common.domain.di.domainModule
import at.asitplus.wallet.app.common.presentation.LocalPresentmentSessionCoordinator
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.scopedOf
import org.koin.core.module.dsl.singleOf
import org.koin.core.qualifier.named
import org.koin.dsl.binds
import org.koin.dsl.module
Expand Down Expand Up @@ -47,13 +48,21 @@ fun appModule(): Module = module {
credentialValidityService = get(),
attestationService = get(),
sessionCoroutineScope = get(),
trustListService = get(),
)
}
scopedOf(::ErrorService)
scopedOf(::LoadingStatusService)
scopedOf(::CredentialValidityService)
scopedOf(::RealCapabilitiesService) binds arrayOf(CapabilitiesService::class)
scopedOf(::IntentService)
scoped {
TrustListService(
persistentTrustListStore = get(),
httpService = get()
)
}

}

includes(dataModule())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import at.asitplus.wallet.lib.agent.SubjectCredentialStore
import data.storage.DataStoreService
import data.storage.HotWalletSubjectCredentialStore
import data.storage.PersistentSubjectCredentialStore
import data.storage.PersistentTrustListStore
import data.storage.WalletSubjectCredentialStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -39,6 +40,7 @@ fun platformModule() = module {
scoped<at.asitplus.wallet.app.common.KeystoreService> { get<WalletSessionBindings>().keystoreService }
scoped<CoroutineScope> { get<WalletSessionBindings>().sessionCoroutineScope }
scopedOf(::PersistentSubjectCredentialStore)
scopedOf(::PersistentTrustListStore)

scoped<WalletKeyMaterial> {
WalletKeyMaterial(get<at.asitplus.wallet.app.common.KeystoreService>().getSignerBlocking())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,35 +57,41 @@ class HotWalletSubjectCredentialStore(
vc: VerifiableCredentialJws,
vcSerialized: String,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo?
renewalInfo: CredentialRenewalInfo?,
issuer: ByteArray?
): SubjectCredentialStore.StoreEntry = delegate.storeCredential(
vc = vc,
vcSerialized = vcSerialized,
scheme = scheme,
renewalInfo = renewalInfo,
issuer = issuer
)

override suspend fun storeCredential(
vc: VerifiableCredentialSdJwt,
vcSerialized: String,
disclosures: Map<String, SelectiveDisclosureItem?>,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo?
renewalInfo: CredentialRenewalInfo?,
issuer: ByteArray?
): SubjectCredentialStore.StoreEntry = delegate.storeCredential(
vc = vc,
vcSerialized = vcSerialized,
disclosures = disclosures,
scheme = scheme,
renewalInfo
renewalInfo = renewalInfo,
issuer = issuer
)

override suspend fun storeCredential(
issuerSigned: IssuerSigned,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo?
renewalInfo: CredentialRenewalInfo?,
issuer: ByteArray?
): SubjectCredentialStore.StoreEntry = delegate.storeCredential(
issuerSigned = issuerSigned,
scheme = scheme,
renewalInfo = renewalInfo
renewalInfo = renewalInfo,
issuer = issuer
)
}
Loading
Loading