Skip to content

Commit c011c25

Browse files
committed
Add issuer settings support and allow deleting pending credentials.
Introduce `CredentialIssuerSettings`, `CredentialIssuerSecureAreaType`, and `CredentialIssuerSettingsAndroidKeySettings` to allow backend provisioning configuration to specify issuer-level Secure Area and device key settings (such as StrongBox enforcement, key algorithm, and user authentication parameters). Update `WalletBackendBase` to parse issuer settings from the provisioning JSON configuration and serialize them alongside `CredentialIssuer` entries. Update Android client provisioning in `App.kt` and `ProvisioningRoute.kt` to store and consume issuer settings via document application data, applying custom `AndroidKeystoreCreateKeySettings` during key creation. Add a delete button and confirmation dialog in `CredentialInfoScreen` for pending credentials so users can remove uncertified credentials. Test: Executed `./gradlew :shared:allTests :backend:test :androidApp:testDebugUnitTest` successfully. Signed-off-by: David Zeuthen <zeuthen@gmail.com>
1 parent e87e9ac commit c011c25

13 files changed

Lines changed: 590 additions & 5 deletions

File tree

androidApp/src/main/java/org/multipaz/wallet/android/App.kt

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,10 @@ import org.multipaz.provisioning.DocumentProvisioningSettings
6464
import org.multipaz.provisioning.ProvisioningModel
6565
import org.multipaz.request.Requester
6666
import org.multipaz.request.TrustedRequesterIdentity
67+
import org.multipaz.securearea.AndroidKeystoreCreateKeySettings
6768
import org.multipaz.securearea.SecureArea
6869
import org.multipaz.securearea.SecureAreaRepository
70+
import org.multipaz.securearea.UserAuthenticationType
6971
import org.multipaz.securearea.software.SoftwareSecureArea
7072
import org.multipaz.storage.Storage
7173
import org.multipaz.trustmanagement.CompositeTrustManager
@@ -90,16 +92,21 @@ import org.multipaz.wallet.client.provisionedDocumentSetupNeeded
9092
import org.multipaz.wallet.client.verification.ProximityReaderModel
9193
import org.multipaz.wallet.shared.BuildConfig
9294
import org.multipaz.wallet.shared.ClientType
95+
import org.multipaz.wallet.shared.CredentialIssuerSecureAreaType
96+
import org.multipaz.wallet.shared.CredentialIssuerSettings
9397
import org.multipaz.wallet.shared.Domains
9498
import org.multipaz.wallet.shared.Location
9599
import org.multipaz.wallet.shared.fromAndroidLocation
100+
import org.multipaz.wallet.shared.fromCbor
101+
import org.multipaz.wallet.shared.toCbor
96102
import org.multipaz.wallet.shared.toDataItem
97103
import java.security.Security
98104

99105
import org.multipaz.nfc.ExternalNfcReaderStore
100106
import org.multipaz.wallet.android.worker.PeriodicBookkeepingScheduler
101107
import org.multipaz.revocation.CachingRevocationChecker
102108
import org.multipaz.revocation.RevocationChecker
109+
import kotlin.time.Duration.Companion.milliseconds
103110

104111
class App private constructor() {
105112

@@ -234,7 +241,41 @@ class App private constructor() {
234241
sdJwtNoUserAuthDomain = Domains.DOMAIN_SDJWT_NO_USER_AUTH,
235242
sdJwtKeylessDomain = Domains.DOMAIN_SDJWT_KEYLESS,
236243
requestNoUserAuth = !settingsModel.disableNoUserAuth.value
237-
)
244+
),
245+
selectSecureArea = { appData, createKeySettings ->
246+
val settings = appData?.let {
247+
CredentialIssuerSettings.fromCbor(it.toByteArray())
248+
}
249+
val targetSecureArea = when (settings?.secureAreaToUse) {
250+
CredentialIssuerSecureAreaType.PLATFORM_SECURE_AREA, CredentialIssuerSecureAreaType.CLOUD_SECURE_AREA, null -> secureArea
251+
}
252+
val aks = settings?.androidKeySettings
253+
if (targetSecureArea == secureArea && aks != null) {
254+
val builder = AndroidKeystoreCreateKeySettings.Builder(createKeySettings.nonce)
255+
.setAlgorithm(aks.algorithm)
256+
.setUseStrongBox(aks.useStrongBox)
257+
if (createKeySettings.userAuthenticationRequired) {
258+
val authTypes = mutableSetOf<UserAuthenticationType>()
259+
if (aks.userAuthenticationLskf) {
260+
authTypes.add(UserAuthenticationType.LSKF)
261+
}
262+
if (aks.userAuthenticationBiometric) {
263+
authTypes.add(UserAuthenticationType.BIOMETRIC)
264+
}
265+
builder.setUserAuthenticationRequired(
266+
true,
267+
aks.userAuthenticationTimeoutMillis.milliseconds,
268+
authTypes
269+
)
270+
}
271+
if (createKeySettings.validFrom != null && createKeySettings.validUntil != null) {
272+
builder.setValidityPeriod(createKeySettings.validFrom!!, createKeySettings.validUntil!!)
273+
}
274+
Pair(targetSecureArea, builder.build())
275+
} else {
276+
Pair(targetSecureArea, createKeySettings)
277+
}
278+
}
238279
),
239280
httpClient = HttpClient(Android) {
240281
followRedirects = false

androidApp/src/main/java/org/multipaz/wallet/android/ui/document/CredentialInfoScreen.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,26 @@ import androidx.compose.foundation.rememberScrollState
1212
import androidx.compose.foundation.verticalScroll
1313
import androidx.compose.material.icons.Icons
1414
import androidx.compose.material.icons.automirrored.filled.ArrowBack
15+
import androidx.compose.material.icons.outlined.Delete
16+
import androidx.compose.material3.AlertDialog
1517
import androidx.compose.material3.ExperimentalMaterial3Api
1618
import androidx.compose.material3.Icon
1719
import androidx.compose.material3.IconButton
1820
import androidx.compose.material3.MaterialTheme
1921
import androidx.compose.material3.MediumTopAppBar
2022
import androidx.compose.material3.Scaffold
2123
import androidx.compose.material3.Text
24+
import androidx.compose.material3.TextButton
2225
import androidx.compose.material3.TopAppBarDefaults
2326
import androidx.compose.material3.rememberTopAppBarState
2427
import androidx.compose.runtime.Composable
2528
import androidx.compose.runtime.LaunchedEffect
2629
import androidx.compose.runtime.collectAsState
30+
import androidx.compose.runtime.getValue
2731
import androidx.compose.runtime.mutableStateOf
2832
import androidx.compose.runtime.remember
2933
import androidx.compose.runtime.rememberCoroutineScope
34+
import androidx.compose.runtime.setValue
3035
import androidx.compose.ui.Modifier
3136
import androidx.compose.ui.input.nestedscroll.nestedScroll
3237
import androidx.compose.ui.text.SpanStyle
@@ -88,10 +93,51 @@ fun CredentialInfoScreen(
8893
onViewCertificateChain: (certChain: X509CertChain) -> Unit,
8994
showToast: (message: String) -> Unit
9095
) {
96+
val coroutineScope = rememberCoroutineScope()
97+
var showDeleteConfirmationDialog by remember { mutableStateOf(false) }
98+
9199
val documentInfos = documentModel.documentInfos.collectAsState().value
92100
val documentInfo = documentInfos.find { it.document.identifier == documentId }
93101
val credentialInfo = documentInfo?.credentialInfos?.find { it.credential.identifier == credentialId }
94102

103+
if (showDeleteConfirmationDialog) {
104+
AlertDialog(
105+
onDismissRequest = { showDeleteConfirmationDialog = false },
106+
title = {
107+
Text(text = stringResource(R.string.credential_info_delete_pending_title))
108+
},
109+
text = {
110+
Text(text = stringResource(R.string.credential_info_delete_pending_text))
111+
},
112+
dismissButton = {
113+
TextButton(
114+
onClick = { showDeleteConfirmationDialog = false }
115+
) {
116+
Text(text = stringResource(R.string.credential_info_delete_pending_cancel))
117+
}
118+
},
119+
confirmButton = {
120+
TextButton(
121+
onClick = {
122+
showDeleteConfirmationDialog = false
123+
coroutineScope.launch {
124+
try {
125+
documentInfo?.document?.deleteCredential(credentialId)
126+
onBackClicked()
127+
} catch (e: Throwable) {
128+
if (e is CancellationException) throw e
129+
Logger.e(TAG, "Failed to delete credential $credentialId", e)
130+
showToast("Failed to delete credential: ${e.message}")
131+
}
132+
}
133+
}
134+
) {
135+
Text(text = stringResource(R.string.credential_info_delete_pending_confirm))
136+
}
137+
}
138+
)
139+
}
140+
95141
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
96142
Scaffold(
97143
modifier = Modifier
@@ -110,6 +156,16 @@ fun CredentialInfoScreen(
110156
)
111157
}
112158
},
159+
actions = {
160+
if (credentialInfo != null && !credentialInfo.credential.isCertified) {
161+
IconButton(onClick = { showDeleteConfirmationDialog = true }) {
162+
Icon(
163+
imageVector = Icons.Outlined.Delete,
164+
contentDescription = stringResource(R.string.credential_info_delete_pending_content_description)
165+
)
166+
}
167+
}
168+
},
113169
scrollBehavior = scrollBehavior
114170
)
115171
}

androidApp/src/main/java/org/multipaz/wallet/android/ui/provisioning/ProvisioningRoute.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
1010
import androidx.compose.runtime.rememberCoroutineScope
1111
import kotlinx.coroutines.CancellationException
1212
import kotlinx.coroutines.launch
13+
import kotlinx.io.bytestring.ByteString
1314
import org.multipaz.provisioning.ProvisioningMetadata
1415
import org.multipaz.provisioning.ProvisioningModel
1516
import org.multipaz.util.Logger
@@ -23,6 +24,8 @@ import org.multipaz.wallet.client.WalletClientProvisionedDocument
2324
import org.multipaz.wallet.client.WalletClientProvisionedDocumentOpenID4VCI
2425
import org.multipaz.wallet.shared.CredentialIssuer
2526
import org.multipaz.wallet.shared.CredentialIssuerOpenID4VCI
27+
import org.multipaz.wallet.shared.fromCbor
28+
import org.multipaz.wallet.shared.toCbor
2629
import kotlin.random.Random
2730

2831
private const val TAG = "ProvisioningRoute"
@@ -54,14 +57,18 @@ fun ProvisioningRoute(
5457
modelResetAtStart.value = true
5558
if (credentialIssuer != null) {
5659
credentialIssuer as CredentialIssuerOpenID4VCI // only one we support right now
60+
val appData = credentialIssuer.credentialIssuerSettings?.let {
61+
ByteString(it.toCbor())
62+
}
5763
if (credentialIssuer.id != null) {
5864
issuerUrl.value = credentialIssuer.url
5965
try {
6066
provisioningModel.launchOpenID4VCIProvisioning(
6167
issuerUrl = credentialIssuer.url,
6268
credentialId = credentialIssuer.id!!,
6369
clientPreferences = walletClient.getOpenID4VCIClientPreferences(),
64-
backend = walletClient.getOpenID4VCIBackend()
70+
backend = walletClient.getOpenID4VCIBackend(),
71+
appData = appData
6572
)
6673
} catch (e: CancellationException) {
6774
throw e
@@ -154,12 +161,16 @@ fun ProvisioningRoute(
154161
onCloseClicked()
155162
},
156163
onCredentialSelected = { selectedId ->
164+
val appData = credentialIssuer?.credentialIssuerSettings?.let {
165+
ByteString(it.toCbor())
166+
}
157167
coroutineScope.launch {
158168
provisioningModel.launchOpenID4VCIProvisioning(
159169
issuerUrl = issuerUrl.value!!,
160170
credentialId = selectedId,
161171
clientPreferences = walletClient.getOpenID4VCIClientPreferences(),
162-
backend = walletClient.getOpenID4VCIBackend()
172+
backend = walletClient.getOpenID4VCIBackend(),
173+
appData = appData
163174
)
164175
}
165176
}

androidApp/src/main/res/values/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,11 @@
744744
<string name="credential_info_http_status">HTTP Status: %1$d</string>
745745
<string name="credential_info_failed_parse_status_list">Failed to parse status list</string>
746746
<string name="credential_info_failed_parse_identifier_list">Failed to parse identifier list</string>
747+
<string name="credential_info_delete_pending_title">Delete pending credential?</string>
748+
<string name="credential_info_delete_pending_text">The pending credential will be permanently deleted. This action cannot be undone.</string>
749+
<string name="credential_info_delete_pending_confirm">Delete</string>
750+
<string name="credential_info_delete_pending_cancel">Cancel</string>
751+
<string name="credential_info_delete_pending_content_description">Delete pending credential</string>
747752
<string name="document_info_credentials_title">Additional pass info</string>
748753
<string name="document_info_refresh_credentials_content_description">Refresh credentials</string>
749754
<string name="document_info_developer_extras_content_description">Developer Extras</string>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package org.multipaz.wallet.android
2+
3+
import kotlinx.coroutines.runBlocking
4+
import kotlinx.io.bytestring.ByteString
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertFalse
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
import org.multipaz.crypto.Algorithm
10+
import org.multipaz.document.buildDocumentStore
11+
import org.multipaz.provisioning.DocumentProvisioningHandler
12+
import org.multipaz.provisioning.DocumentProvisioningSettings
13+
import org.multipaz.securearea.AndroidKeystoreCreateKeySettings
14+
import org.multipaz.securearea.CreateKeySettings
15+
import org.multipaz.securearea.SecureAreaRepository
16+
import org.multipaz.securearea.UserAuthenticationType
17+
import org.multipaz.securearea.software.SoftwareSecureArea
18+
import org.multipaz.storage.ephemeral.EphemeralStorage
19+
import org.multipaz.wallet.shared.CredentialIssuerSecureAreaType
20+
import org.multipaz.wallet.shared.CredentialIssuerSettings
21+
import org.multipaz.wallet.shared.CredentialIssuerSettingsAndroidKeySettings
22+
import org.multipaz.wallet.shared.fromCbor
23+
import org.multipaz.wallet.shared.toCbor
24+
import kotlin.time.Duration.Companion.seconds
25+
26+
class ProvisioningSecureAreaSelectionTest {
27+
28+
@Test
29+
fun testSelectSecureAreaWithStrongBoxSettings() = runBlocking {
30+
val storage = EphemeralStorage()
31+
val secureArea = SoftwareSecureArea.create(storage)
32+
val secureAreaRepository = SecureAreaRepository.Builder()
33+
.add(secureArea)
34+
.build()
35+
val documentStore = buildDocumentStore(storage = storage, secureAreaRepository = secureAreaRepository) {}
36+
37+
val handler = DocumentProvisioningHandler(
38+
documentStore = documentStore,
39+
secureArea = secureArea,
40+
defaultDocumentProvisioningSettings = DocumentProvisioningSettings(),
41+
selectSecureArea = { appData, createKeySettings ->
42+
val settings = appData?.let {
43+
CredentialIssuerSettings.fromCbor(it.toByteArray())
44+
}
45+
settings?.androidKeySettings?.let { aks ->
46+
val builder = AndroidKeystoreCreateKeySettings.Builder(createKeySettings.nonce)
47+
.setAlgorithm(createKeySettings.algorithm)
48+
.setUseStrongBox(aks.useStrongBox)
49+
if (createKeySettings.userAuthenticationRequired) {
50+
val authTypes = mutableSetOf<UserAuthenticationType>()
51+
if (aks.userAuthenticationLskf) {
52+
authTypes.add(UserAuthenticationType.LSKF)
53+
}
54+
if (aks.userAuthenticationBiometric) {
55+
authTypes.add(UserAuthenticationType.BIOMETRIC)
56+
}
57+
builder.setUserAuthenticationRequired(
58+
true,
59+
createKeySettings.userAuthenticationTimeout,
60+
authTypes
61+
)
62+
}
63+
if (createKeySettings.validFrom != null && createKeySettings.validUntil != null) {
64+
builder.setValidityPeriod(createKeySettings.validFrom!!, createKeySettings.validUntil!!)
65+
}
66+
Pair(secureArea, builder.build())
67+
} ?: Pair(secureArea, createKeySettings)
68+
}
69+
)
70+
71+
val settings = CredentialIssuerSettings(
72+
secureAreaToUse = CredentialIssuerSecureAreaType.PLATFORM_SECURE_AREA,
73+
androidKeySettings = CredentialIssuerSettingsAndroidKeySettings(
74+
useStrongBox = true
75+
)
76+
)
77+
val strongBoxAppData = ByteString(settings.toCbor())
78+
val docWithStrongBox = documentStore.createDocument(
79+
displayName = "Utopia PID (StrongBox)",
80+
typeDisplayName = "PID",
81+
cardArt = null,
82+
issuerLogo = null,
83+
authorizationData = null,
84+
appData = strongBoxAppData,
85+
metadata = null
86+
)
87+
88+
val initialSettings = CreateKeySettings(
89+
algorithm = Algorithm.ESP256,
90+
nonce = ByteString(byteArrayOf(1, 2, 3)),
91+
userAuthenticationRequired = true,
92+
userAuthenticationTimeout = 30.seconds,
93+
validFrom = null,
94+
validUntil = null
95+
)
96+
97+
val (_, selectedSettings) = handler.selectSecureArea(docWithStrongBox, initialSettings)
98+
assertTrue(selectedSettings is AndroidKeystoreCreateKeySettings)
99+
val androidKeystoreSettings = selectedSettings as AndroidKeystoreCreateKeySettings
100+
assertTrue(androidKeystoreSettings.useStrongBox)
101+
assertEquals(Algorithm.ESP256, androidKeystoreSettings.algorithm)
102+
assertTrue(androidKeystoreSettings.userAuthenticationRequired)
103+
assertEquals(30.seconds, androidKeystoreSettings.userAuthenticationTimeout)
104+
assertTrue(androidKeystoreSettings.userAuthenticationTypes.contains(UserAuthenticationType.LSKF))
105+
assertTrue(androidKeystoreSettings.userAuthenticationTypes.contains(UserAuthenticationType.BIOMETRIC))
106+
107+
// Normal document without StrongBox appData
108+
val normalDoc = documentStore.createDocument(
109+
displayName = "Utopia PID",
110+
typeDisplayName = "PID",
111+
cardArt = null,
112+
issuerLogo = null,
113+
authorizationData = null,
114+
appData = null,
115+
metadata = null
116+
)
117+
118+
val (_, normalSettings) = handler.selectSecureArea(normalDoc, initialSettings)
119+
assertFalse(normalSettings is AndroidKeystoreCreateKeySettings)
120+
assertEquals(initialSettings, normalSettings)
121+
}
122+
}

backend/src/main/resources/resources/default_configuration.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,18 @@
153153
"url": "https://issuer.multipaz.org/issuer",
154154
"id": "mDL"
155155
},
156+
{
157+
"name": "Utopia Driving License (StrongBox)",
158+
"icon_url": "https://issuer.multipaz.org/issuer/card-mdl.png",
159+
"type": "openid4vci",
160+
"url": "https://issuer.multipaz.org/issuer",
161+
"id": "mDL",
162+
"settings": {
163+
"android_key_settings": {
164+
"use_strongbox": true
165+
}
166+
}
167+
},
156168
{
157169
"name": "Utopia PID",
158170
"icon_url": "https://issuer.multipaz.org/issuer/card-pid.png",

0 commit comments

Comments
 (0)