Skip to content

Commit e738b4c

Browse files
committed
fix: enforce protected key authorization invariants (WAL-1183)
1 parent c942f41 commit e738b4c

13 files changed

Lines changed: 434 additions & 21 deletions

File tree

waltid-libraries/crypto/waltid-crypto/src/androidMain/kotlin/id/walt/crypto/AndroidKey.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import kotlinx.serialization.json.Json
2828
import kotlinx.serialization.json.JsonElement
2929
import kotlinx.serialization.json.JsonObject
3030
import org.bouncycastle.jce.provider.BouncyCastleProvider
31+
import java.lang.ref.WeakReference
3132
import kotlin.io.encoding.Base64
3233
import kotlin.time.Duration
3334
import kotlin.uuid.Uuid
@@ -41,7 +42,22 @@ sealed class AndroidKey : Key() {
4142
val authorizationPrompt: KeyUseAuthorizationPrompt = KeyUseAuthorizationPrompt(),
4243
interactionContext: Any? = null,
4344
) {
44-
internal val interactionContext: FragmentActivity? = interactionContext as? FragmentActivity
45+
private val interactionContextProvider: () -> FragmentActivity? = when (interactionContext) {
46+
is FragmentActivity -> {
47+
val reference = WeakReference(interactionContext)
48+
val provider: () -> FragmentActivity? = { reference.get() }
49+
provider
50+
}
51+
is Function0<*> -> {
52+
{ interactionContext.invoke() as? FragmentActivity }
53+
}
54+
else -> {
55+
{ null }
56+
}
57+
}
58+
59+
internal val interactionContext: FragmentActivity?
60+
get() = interactionContextProvider().takeIf { it.canHostBiometricPrompt() }
4561
}
4662

4763
class Platform internal constructor(
@@ -253,6 +269,9 @@ private fun at.asitplus.signum.supreme.os.AndroidSigningKeyConfiguration.configu
253269
}
254270
}
255271

272+
private fun FragmentActivity?.canHostBiometricPrompt(): Boolean =
273+
this != null && !isFinishing && !isDestroyed && !isChangingConfigurations
274+
256275
private suspend fun AndroidKey.Options.loadSigner(): AndroidKeystoreSigner {
257276
val signer = AndroidKeyStoreProvider.getSignerForKey(kid) {
258277
unlockPrompt {

waltid-libraries/protocols/waltid-openid4vc-wallet-mobile/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ Android Keystore key restricted to strong biometrics; the factory must receive a
6363
current Face ID or Touch ID enrollment set and therefore requires a qualifying physical device.
6464
Protected non-P-256 requests fail without software fallback.
6565

66+
`MobileWalletFactory(activity)` weakly references that activity. If the wallet outlives one activity
67+
instance, use `MobileWalletFactory(applicationContext) { activityTracker.currentFragmentActivity }`
68+
so protected operations resolve the current prompt host after configuration changes.
69+
6670
This setting applies only when a key is created. It does not reclassify, replace, or rotate an existing
6771
key. Inspect `wallet.keys()` for each key's requested and effective policy and effective hardware
6872
backing when the platform can report it reliably. Changing the default affects future keys only.
@@ -156,7 +160,13 @@ val config = MobileWalletConfig(
156160
```
157161
<!-- doc-snippet:end kotlin-custom-credential-store -->
158162

159-
KMP consumers can override all wallet stores. Key storage and key generation are configured together so platform-managed signing keys cannot be accidentally mixed with app-owned key persistence. The legacy `(KeyType) -> Key` generator supports `None` only; protected requests require an authorization-aware request generator and capability implementation and are never inferred from the legacy callback:
163+
KMP consumers can override all wallet stores. Key storage and key generation are configured together
164+
so platform-managed signing keys cannot be accidentally mixed with app-owned key persistence. The
165+
legacy `(KeyType) -> Key` generator supports `None` only; protected requests require an
166+
authorization-aware request generator and capability implementation and are never inferred from the
167+
legacy callback. A custom key store must also override `supportsKeyUseAuthorizationMetadata` and the
168+
metadata-aware `addKey` overload only after it preserves and enforces the requested policy; otherwise
169+
protected bootstrap fails before generating a key:
160170

161171
<!-- doc-snippet:start kotlin-full-store-overrides -->
162172
```kotlin

waltid-libraries/protocols/waltid-openid4vc-wallet-mobile/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ kotlin {
6767
}
6868
if (enableAndroidBuild) {
6969
androidMain.dependencies {
70+
implementation(identityLibs.androidx.biometric)
7071
implementation(identityLibs.ktor.client.android)
7172
}
7273
}
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,65 @@
11
package id.walt.wallet2.mobile
22

33
import android.content.Context
4+
import androidx.fragment.app.FragmentActivity
45
import id.walt.wallet2.persistence.encryption.AndroidDatabaseEncryptionKeyProvider
56
import id.walt.wallet2.persistence.keys.AndroidPlatformKeyProvider
67
import id.walt.wallet2.persistence.stores.DriverFactory
8+
import java.lang.ref.WeakReference
79

810
/**
911
* Android [MobileWallet] factory backed by Android KeyStore and an app-private SQLDelight database.
1012
*
11-
* @param context Android context used to open the wallet database. Protected key use requires this
12-
* context to be an interactive [androidx.fragment.app.FragmentActivity].
13+
* The single-context constructor weakly references an interactive [FragmentActivity]. Apps that retain
14+
* a wallet across activity recreation should use the provider-based constructor.
1315
*/
14-
public actual class MobileWalletFactory(private val context: Context) {
16+
public actual class MobileWalletFactory private constructor(
17+
private val applicationContext: Context,
18+
private val interactionContextProvider: () -> FragmentActivity?,
19+
@Suppress("UNUSED_PARAMETER") marker: Unit,
20+
) {
21+
/** Creates an activity-scoped factory while retaining only the application context strongly. */
22+
public constructor(context: Context) : this(
23+
applicationContext = context.applicationContext,
24+
interactionContextProvider = weakInteractionContextProvider(context as? FragmentActivity),
25+
marker = Unit,
26+
)
27+
28+
/** Creates a factory that resolves the current activity after configuration changes. */
29+
public constructor(
30+
context: Context,
31+
interactionContextProvider: () -> FragmentActivity?,
32+
) : this(
33+
applicationContext = context.applicationContext,
34+
interactionContextProvider = interactionContextProvider,
35+
marker = Unit,
36+
)
37+
1538
/**
1639
* Creates an Android mobile wallet for [config].
1740
*
1841
* The database is named from [MobileWalletConfig.walletId], and signing keys are created or loaded
1942
* through the Android platform key provider.
2043
*/
2144
public actual suspend fun create(config: MobileWalletConfig): MobileWallet {
22-
val driverFactory = DriverFactory(context)
45+
val driverFactory = DriverFactory(applicationContext)
2346
return createEncryptedSqlDelightMobileWallet(
2447
config = config,
25-
managedDatabaseKeyProvider = AndroidDatabaseEncryptionKeyProvider(context),
48+
managedDatabaseKeyProvider = AndroidDatabaseEncryptionKeyProvider(applicationContext),
2649
platformKeyProvider = AndroidPlatformKeyProvider(
27-
context = context,
50+
context = applicationContext,
51+
interactionContextProvider = interactionContextProvider,
2852
authorizationPrompt = config.keyUseAuthorizationPrompt,
2953
),
3054
openEncryptedDriver = driverFactory::createEncryptedDriver,
3155
deleteDatabase = driverFactory::deleteDatabase,
3256
)
3357
}
58+
59+
private companion object {
60+
fun weakInteractionContextProvider(activity: FragmentActivity?): () -> FragmentActivity? {
61+
val reference = activity?.let(::WeakReference)
62+
return { reference?.get() }
63+
}
64+
}
3465
}

waltid-libraries/protocols/waltid-openid4vc-wallet-mobile/src/commonMain/kotlin/id/walt/wallet2/mobile/MobileWallet.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ public class MobileWallet internal constructor(
278278

279279
val effectiveKeyType = keyType ?: defaultKeyType
280280
val effectiveAuthorizationPolicy = keyUseAuthorizationPolicy ?: defaultKeyUseAuthorizationPolicy
281-
val capability = keyCapability(effectiveKeyType.toKeyType(), effectiveAuthorizationPolicy)
281+
val capability = keyUseAuthorizationCapability(effectiveKeyType, effectiveAuthorizationPolicy)
282282
if (!capability.supported) {
283283
throw KeyUseAuthorizationException(
284284
failure = capability.failure ?: KeyUseAuthorizationFailure.UnsupportedCombination,
@@ -341,7 +341,21 @@ public class MobileWallet internal constructor(
341341
public suspend fun keyUseAuthorizationCapability(
342342
keyType: MobileWalletKeyType = defaultKeyType,
343343
keyUseAuthorizationPolicy: KeyUseAuthorizationPolicy = defaultKeyUseAuthorizationPolicy,
344-
): PlatformKeyCapability = keyCapability(keyType.toKeyType(), keyUseAuthorizationPolicy)
344+
): PlatformKeyCapability {
345+
val capability = keyCapability(keyType.toKeyType(), keyUseAuthorizationPolicy)
346+
return if (
347+
keyUseAuthorizationPolicy != KeyUseAuthorizationPolicy.None &&
348+
capability.supported &&
349+
!keyStore.supportsKeyUseAuthorizationMetadata
350+
) {
351+
capability.copy(
352+
supported = false,
353+
failure = KeyUseAuthorizationFailure.UnsupportedCombination,
354+
)
355+
} else {
356+
capability
357+
}
358+
}
345359

346360
private suspend fun registerDidByKey(didMethod: String, key: Key) =
347361
when (didMethod.lowercase()) {

waltid-libraries/protocols/waltid-openid4vc-wallet-mobile/src/commonTest/kotlin/id/walt/wallet2/mobile/MobileWalletTest.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,46 @@ class MobileWalletTest {
118118
assertEquals(0, keyStore.addKeyCalls)
119119
}
120120

121+
@Test
122+
fun protectedBootstrapFailsPreflightWhenCustomStoreCannotPreservePolicy() = runTest {
123+
val keyStore = EmptyKeyStore()
124+
var generationCalls = 0
125+
val wallet = MobileWallet(
126+
walletId = "custom-store-wallet",
127+
keyStore = keyStore,
128+
didStore = EmptyDidStore(),
129+
credentialStore = RecordingCredentialStore(),
130+
keyGenerator = { _: PlatformKeyGenerationRequest ->
131+
generationCalls++
132+
error("Unsupported persistence must be rejected before key generation")
133+
},
134+
keyCapability = { keyType, policy ->
135+
PlatformKeyCapability(
136+
platform = PlatformKeyPlatform.Custom,
137+
keyType = keyType,
138+
keyUseAuthorizationPolicy = policy,
139+
supported = true,
140+
platformBackingAvailable = true,
141+
secureHardwareRequired = false,
142+
secureHardwareAvailable = null,
143+
)
144+
},
145+
)
146+
147+
val capability = wallet.keyUseAuthorizationCapability(
148+
keyUseAuthorizationPolicy = KeyUseAuthorizationPolicy.BiometricCurrentSet,
149+
)
150+
val failure = assertFailsWith<KeyUseAuthorizationException> {
151+
wallet.bootstrap(keyUseAuthorizationPolicy = KeyUseAuthorizationPolicy.BiometricCurrentSet)
152+
}
153+
154+
assertFalse(capability.supported)
155+
assertEquals(KeyUseAuthorizationFailure.UnsupportedCombination, capability.failure)
156+
assertEquals(KeyUseAuthorizationFailure.UnsupportedCombination, failure.failure)
157+
assertEquals(0, generationCalls)
158+
assertEquals(0, keyStore.addKeyCalls)
159+
}
160+
121161
@Test
122162
fun changingDefaultDoesNotReclassifyOrReplaceExistingKey() = runTest {
123163
val existing = WalletKeyInfo(

waltid-libraries/protocols/waltid-openid4vc-wallet-persistence-mobile/api/waltid-openid4vc-wallet-persistence-mobile.klib.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ final class id.walt.wallet2.persistence.stores/DriverFactory { // id.walt.wallet
265265
final class id.walt.wallet2.persistence.stores/PlatformKeyStore : id.walt.wallet2.data/WalletKeyStore { // id.walt.wallet2.persistence.stores/PlatformKeyStore|null[0]
266266
constructor <init>(id.walt.wallet2.persistence.keys/PlatformKeyProvider, id.walt.wallet2.persistence.db/WalletPersistenceQueries) // id.walt.wallet2.persistence.stores/PlatformKeyStore.<init>|<init>(id.walt.wallet2.persistence.keys.PlatformKeyProvider;id.walt.wallet2.persistence.db.WalletPersistenceQueries){}[0]
267267

268+
final val supportsKeyUseAuthorizationMetadata // id.walt.wallet2.persistence.stores/PlatformKeyStore.supportsKeyUseAuthorizationMetadata|{}supportsKeyUseAuthorizationMetadata[0]
269+
final fun <get-supportsKeyUseAuthorizationMetadata>(): kotlin/Boolean // id.walt.wallet2.persistence.stores/PlatformKeyStore.supportsKeyUseAuthorizationMetadata.<get-supportsKeyUseAuthorizationMetadata>|<get-supportsKeyUseAuthorizationMetadata>(){}[0]
270+
268271
final suspend fun addKey(id.walt.crypto.keys/Key): kotlin/String // id.walt.wallet2.persistence.stores/PlatformKeyStore.addKey|addKey(id.walt.crypto.keys.Key){}[0]
269272
final suspend fun addKey(id.walt.crypto.keys/Key, id.walt.wallet2.data/WalletKeyInfo): kotlin/String // id.walt.wallet2.persistence.stores/PlatformKeyStore.addKey|addKey(id.walt.crypto.keys.Key;id.walt.wallet2.data.WalletKeyInfo){}[0]
270273
final suspend fun getKey(kotlin/String): id.walt.crypto.keys/Key? // id.walt.wallet2.persistence.stores/PlatformKeyStore.getKey|getKey(kotlin.String){}[0]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<application>
3+
<activity
4+
android:name="id.walt.wallet2.persistence.keys.PlatformKeyProviderTestActivity"
5+
android:exported="false" />
6+
</application>
7+
</manifest>

waltid-libraries/protocols/waltid-openid4vc-wallet-persistence-mobile/src/androidDeviceTest/kotlin/id/walt/wallet2/persistence/keys/AndroidPlatformKeyProviderTest.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
package id.walt.wallet2.persistence.keys
22

3+
import androidx.fragment.app.FragmentActivity
4+
import androidx.test.ext.junit.rules.ActivityScenarioRule
35
import androidx.test.ext.junit.runners.AndroidJUnit4
46
import id.walt.crypto.keys.KeyType
57
import id.walt.crypto.keys.KeyUseAuthorizationException
68
import id.walt.crypto.keys.KeyUseAuthorizationFailure
79
import id.walt.crypto.keys.KeyUseAuthorizationPolicy
810
import kotlinx.coroutines.test.runTest
11+
import org.junit.Rule
912
import org.junit.Test
1013
import org.junit.runner.RunWith
1114
import kotlin.test.assertEquals
1215
import kotlin.test.assertFailsWith
1316
import kotlin.test.assertFalse
17+
import kotlin.test.assertNotEquals
18+
19+
class PlatformKeyProviderTestActivity : FragmentActivity()
1420

1521
@RunWith(AndroidJUnit4::class)
1622
class AndroidPlatformKeyProviderTest {
1723

24+
@get:Rule
25+
val activityRule = ActivityScenarioRule(PlatformKeyProviderTestActivity::class.java)
26+
1827
@Test
1928
fun nonePreservesExistingAlgorithmSupport() = runTest {
2029
val provider = AndroidPlatformKeyProvider()
@@ -59,4 +68,47 @@ class AndroidPlatformKeyProviderTest {
5968
}
6069
assertEquals(KeyUseAuthorizationFailure.UnsupportedCombination, failure.failure)
6170
}
71+
72+
@Test
73+
fun currentActivityProviderSurvivesRecreationWithoutRetainingDestroyedActivity() = runTest {
74+
var currentActivity = activityRule.scenario.withActivity()
75+
val applicationContext = currentActivity.applicationContext
76+
val activityScopedProvider = AndroidPlatformKeyProvider(currentActivity)
77+
val recreationAwareProvider = AndroidPlatformKeyProvider(
78+
context = applicationContext,
79+
interactionContextProvider = { currentActivity },
80+
)
81+
82+
assertNotEquals(
83+
KeyUseAuthorizationFailure.InteractionContextUnavailable,
84+
recreationAwareProvider.capability(
85+
KeyType.secp256r1,
86+
KeyUseAuthorizationPolicy.BiometricCurrentSet,
87+
).failure,
88+
)
89+
90+
activityRule.scenario.recreate()
91+
currentActivity = activityRule.scenario.withActivity()
92+
93+
assertEquals(
94+
KeyUseAuthorizationFailure.InteractionContextUnavailable,
95+
activityScopedProvider.capability(
96+
KeyType.secp256r1,
97+
KeyUseAuthorizationPolicy.BiometricCurrentSet,
98+
).failure,
99+
)
100+
assertNotEquals(
101+
KeyUseAuthorizationFailure.InteractionContextUnavailable,
102+
recreationAwareProvider.capability(
103+
KeyType.secp256r1,
104+
KeyUseAuthorizationPolicy.BiometricCurrentSet,
105+
).failure,
106+
)
107+
}
108+
109+
private fun <A : FragmentActivity> androidx.test.core.app.ActivityScenario<A>.withActivity(): A {
110+
lateinit var activity: A
111+
onActivity { activity = it }
112+
return activity
113+
}
62114
}

0 commit comments

Comments
 (0)