Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* Performance: Request a status list once for credentials sharing it that are checked at the same time, and no longer let a status list being requested hold up cached status lists and other ones, which were all checked one after another
* Performance: Do not request a status list, Trust List, or type metadata again for 5 minutes after a request without a response, e.g. offline or timed out, or with an invalid document, instead of waiting for it to fail on every check; give up connecting after 10 seconds and on a stalled response after 15 seconds instead of the platform's default. Issuing still checks the status of a received credential with a fresh status list, and “Refresh trust lists” still loads every list
* Performance: Cache the HTTP response of each status list and Trust List on its own, so that revalidating one no longer decodes and encodes every cached response, e.g. all Trust Lists; the previous cache is dropped once
* Issuing: Show every issued credential on the result screen, also when the list of credentials has not yet been loaded again, which on a busy device left out all but the first one

# Release 5.9.1:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ class ProvisioningService(
storeCredential(credential, issued.scheme, issued.refreshToken, underReview)
}
val needsReview = storageResults.any { it.getOrNull() == null }
val storedEntryIds = resolveNewStoreEntryIds(idsBefore, storageResults.count { it.getOrNull() != null })
val storedEntryIds = storedEntryIds(idsBefore, storageResults.mapNotNull { it.getOrNull() })
Napier.d("resumeWithAuthCode resolved storeEntryIds=$storedEntryIds")

if (storageResults.all { it.isSuccess }) {
Expand Down Expand Up @@ -514,7 +514,7 @@ class ProvisioningService(
storeCredential(credential, issued.scheme, underReview = underReview).getOrThrow()
}
if (underReview.isNotEmpty()) pendingCredentialReview.replace(underReview)
val storedEntryIds = resolveNewStoreEntryIds(idsBefore, storedEntries.count { it != null })
val storedEntryIds = storedEntryIds(idsBefore, storedEntries.filterNotNull())
Napier.d("loadCredentialWithOffer resolved storeEntryIds=$storedEntryIds")
StoredCredentialIssuanceResult(
credentialIssuanceResult = result,
Expand All @@ -534,8 +534,8 @@ class ProvisioningService(
suspend fun keepCredentialsUnderReview(): List<StoreEntryId> {
val idsBefore = subjectCredentialStore.observeStoreContainer().first().credentials.map { it.first }.toSet()
val entries = pendingCredentialReview.take()
entries.forEach { storeAfterUserConsent(it.prepared) }
return resolveNewStoreEntryIds(idsBefore, entries.size)
val stored = entries.map { storeAfterUserConsent(it.prepared) }
return storedEntryIds(idsBefore, stored)
}

/** Drops every credential under review, as the user decided not to keep it. */
Expand Down Expand Up @@ -598,6 +598,18 @@ class ProvisioningService(
* Waits for the store to emit a container with all [count] entries stored since [idsBefore], as the session's hot
* store may still show only some of them, returning those found so far on a timeout.
*/
/**
* The IDs of [stored], as the store assigned them. The session's hot store shows new entries only once it decoded
* the whole container again, which took longer than [resolveNewStoreEntryIds] waits on a busy device, so the
* result screen showed only some of the issued credentials.
*/
private suspend fun storedEntryIds(
idsBefore: Set<StoreEntryId>,
stored: List<SubjectCredentialStore.StoreEntry>,
): List<StoreEntryId> = stored.map { subjectCredentialStore.storeEntryIdOf(it) }
.takeIf { ids -> ids.none { it == null } }?.filterNotNull()
?: resolveNewStoreEntryIds(idsBefore, stored.size)

private suspend fun resolveNewStoreEntryIds(idsBefore: Set<StoreEntryId>, count: Int): List<StoreEntryId> {
if (count == 0) return emptyList()
var newIds = emptyList<StoreEntryId>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class HotWalletSubjectCredentialStore(
override suspend fun getCredentials(credentialSchemes: Collection<CredentialScheme>?) =
super.getCredentials(credentialSchemes)

override fun storeEntryIdOf(entry: SubjectCredentialStore.StoreEntry) = delegate.storeEntryIdOf(entry)

override suspend fun removeStoreEntryById(
storeEntryId: StoreEntryId,
) = delegate.removeStoreEntryById(storeEntryId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import at.asitplus.wallet.lib.data.VcJwtCredentialScheme
import at.asitplus.wallet.lib.data.VerifiableCredentialJws
import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt
import io.github.aakira.napier.Napier
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
Expand All @@ -35,13 +37,25 @@ class PersistentSubjectCredentialStore(
) : SubjectCredentialStore, WalletSubjectCredentialStore {
private val container = this.observeStoreContainer()

// The IDs of the entries stored last, by identity, see [storeEntryIdOf]
private val recentlyStored = ArrayDeque<Pair<SubjectCredentialStore.StoreEntry, StoreEntryId>>()
private val recentlyStoredLock = SynchronizedObject()

private suspend fun addStoreEntry(storeEntry: SubjectCredentialStore.StoreEntry) {
val id = Random.nextLong()
val newContainer = container.first().let {
it.copy(it.credentials + listOf(Random.nextLong() to storeEntry))
it.copy(it.credentials + listOf(id to storeEntry))
}
exportToDataStore(newContainer)
synchronized(recentlyStoredLock) {
recentlyStored.addLast(storeEntry to id)
while (recentlyStored.size > RECENTLY_STORED_LIMIT) recentlyStored.removeFirst()
}
}

override fun storeEntryIdOf(entry: SubjectCredentialStore.StoreEntry): StoreEntryId? =
synchronized(recentlyStoredLock) { recentlyStored.lastOrNull { it.first === entry }?.second }

override suspend fun storeCredential(
entry: SubjectCredentialStore.StoreEntry
): KmmResult<SubjectCredentialStore.StoreEntry> = catching {
Expand Down Expand Up @@ -252,6 +266,8 @@ class PersistentSubjectCredentialStore(

typealias StoreEntryId = Long

private const val RECENTLY_STORED_LIMIT = 32

@Serializable
data class StoreContainer(
val credentials: List<Pair<StoreEntryId, SubjectCredentialStore.StoreEntry>>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ interface WalletSubjectCredentialStore : SubjectCredentialStore {

suspend fun removeStoreEntryById(storeEntryId: StoreEntryId)

/**
* The ID that [entry] has been stored with by this store instance recently, e.g. as returned by
* [at.asitplus.wallet.lib.agent.HolderAgent.storeAccepted], without waiting for [observeStoreContainer] to show it.
*/
fun storeEntryIdOf(entry: StoreEntry): StoreEntryId? = null

override suspend fun getCredentials(
credentialSchemes: Collection<CredentialScheme>?,
): KmmResult<List<StoreEntry>> {
Expand Down
51 changes: 51 additions & 0 deletions shared/src/commonTest/kotlin/data/storage/StoreEntryIdTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package data.storage

import at.asitplus.iso.IssuerSignedItem
import at.asitplus.openid.OidcUserInfo
import at.asitplus.openid.OidcUserInfoExtended
import at.asitplus.wallet.lib.agent.CredentialToBeIssued
import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert
import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert
import at.asitplus.wallet.lib.agent.HolderAgent
import at.asitplus.wallet.lib.agent.IssuerAgent
import at.asitplus.wallet.lib.agent.toStoreCredentialInput
import at.asitplus.wallet.lib.data.IsoMdocFallbackCredentialScheme
import at.asitplus.wallet.lib.data.rfc3986.toUri
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes

class StoreEntryIdTest {
private val keyMaterial = EphemeralKeyWithoutCert()
private val issuer = IssuerAgent(
keyMaterial = EphemeralKeyWithSelfSignedCert(),
identifier = "https://issuer.example.com/".toUri(),
)

@Test
fun knowsTheIdsOfStoredEntries() = runTest {
val store = PersistentSubjectCredentialStore(DummyDataStoreService())
val holder = HolderAgent(keyMaterial = keyMaterial, subjectCredentialStore = store)

val stored = listOf("Erika", "Max").map { holder.storeCredential(issue(it)).getOrThrow() }

assertEquals(
store.observeStoreContainer().first().credentials.map { it.first },
stored.map { store.storeEntryIdOf(it) },
)
}

private suspend fun issue(givenName: String) = issuer.issueCredential(
CredentialToBeIssued.Iso(
issuerSignedItems = listOf(IssuerSignedItem(0U, Random.nextBytes(16), "given_name", givenName)),
expiration = Clock.System.now() + 10.minutes,
scheme = IsoMdocFallbackCredentialScheme("org.example.unknown.1"),
subjectPublicKey = keyMaterial.publicKey,
userInfo = OidcUserInfoExtended.fromOidcUserInfo(OidcUserInfo("subject")).getOrThrow(),
)
).getOrThrow().toStoreCredentialInput()
}
Loading