Skip to content

Commit c942f41

Browse files
committed
feat: add biometric key-use authorization (WAL-1183)
1 parent 84cda6f commit c942f41

46 files changed

Lines changed: 2636 additions & 87 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ smiley4-schema-kenerator = "2.7.2"
129129
# a-sit-plus signum (mobile crypto)
130130
signum = "3.24.0"
131131
supreme = "0.15.0"
132+
androidx-biometric = "1.2.0-alpha05"
132133

133134
[libraries]
134135

@@ -365,6 +366,7 @@ kedis = { module = "io.github.domgew:kedis", version.ref = "kedis" }
365366
signum-indispensable = { module = "at.asitplus.signum:indispensable", version.ref = "signum" }
366367
signum-indispensable-josef = { module = "at.asitplus.signum:indispensable-josef", version.ref = "signum" }
367368
signum-supreme = { module = "at.asitplus.signum:supreme", version.ref = "supreme" }
369+
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidx-biometric" }
368370

369371
## dev.whyoleg cryptography (Ed25519/secp256k1 on mobile)
370372
cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "whyoleg-cryptography" }

waltid-libraries/crypto/waltid-crypto/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ kotlin {
6565
named("androidMain") {
6666
dependsOn(mobileMain)
6767
dependencies {
68+
implementation(identityLibs.androidx.biometric)
6869
implementation(identityLibs.kotlinx.coroutines.android)
6970
implementation(identityLibs.cryptography.provider.jdk)
7071
}
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.crypto.BiometricTestActivity"
5+
android:exported="false" />
6+
</application>
7+
</manifest>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package id.walt.crypto
2+
3+
import androidx.fragment.app.FragmentActivity
4+
import androidx.test.ext.junit.rules.ActivityScenarioRule
5+
import androidx.test.ext.junit.runners.AndroidJUnit4
6+
import id.walt.crypto.keys.KeyUseAuthorizationFailure
7+
import id.walt.crypto.keys.KeyUseAuthorizationPolicy
8+
import id.walt.crypto.keys.KeyUseAuthorizationException
9+
import id.walt.crypto.keys.KeyType
10+
import kotlinx.coroutines.test.runTest
11+
import org.junit.After
12+
import org.junit.Ignore
13+
import org.junit.Rule
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertFailsWith
18+
import kotlin.test.assertTrue
19+
20+
/** Minimal host for Signum's BiometricPrompt CryptoObject flow. */
21+
class BiometricTestActivity : FragmentActivity()
22+
23+
/**
24+
* Interactive device coverage for the protected-key acceptance path.
25+
*
26+
* Run these tests manually on an emulator or device with a strong biometric enrolled. The operator
27+
* must authenticate or cancel the operating-system prompt as described by each test; they are
28+
* ignored in unattended CI so no test harness can silently substitute device credentials.
29+
*/
30+
@RunWith(AndroidJUnit4::class)
31+
class BiometricProtectedKeyInteractiveTest {
32+
33+
@get:Rule
34+
val activityRule = ActivityScenarioRule(BiometricTestActivity::class.java)
35+
36+
private val aliases = mutableListOf<String>()
37+
38+
@After
39+
fun cleanup() = runTest {
40+
aliases.forEach { alias -> runCatching { AndroidKey.Platform.delete(alias) } }
41+
}
42+
43+
@Ignore("Interactive: authenticate the strong-biometric prompt twice on an enrolled device")
44+
@Test
45+
fun protectedP256PromptsForEverySigningOperationAndVerifies() = runTest {
46+
val activity = activityRule.scenario.withActivity()
47+
val alias = "test-biometric-every-use-${System.currentTimeMillis()}".also(aliases::add)
48+
val key = AndroidKey.Platform.create(
49+
AndroidKey.Options(
50+
kid = alias,
51+
keyType = KeyType.secp256r1,
52+
keyUseAuthorizationPolicy = KeyUseAuthorizationPolicy.BiometricCurrentSet,
53+
interactionContext = activity,
54+
)
55+
)
56+
val plaintext = "authorize every key use".encodeToByteArray()
57+
58+
val first = key.signRaw(plaintext)
59+
val second = key.signRaw(plaintext)
60+
61+
assertTrue(first.isNotEmpty())
62+
assertTrue(second.isNotEmpty())
63+
assertTrue(key.verifyRaw(first, plaintext).isSuccess)
64+
assertTrue(key.verifyRaw(second, plaintext).isSuccess)
65+
}
66+
67+
@Ignore("Interactive: cancel the strong-biometric prompt on an enrolled device")
68+
@Test
69+
fun cancelledPromptProducesNoSignatureAndStableFailure() = runTest {
70+
val activity = activityRule.scenario.withActivity()
71+
val alias = "test-biometric-cancel-${System.currentTimeMillis()}".also(aliases::add)
72+
val key = AndroidKey.Platform.create(
73+
AndroidKey.Options(
74+
kid = alias,
75+
keyType = KeyType.secp256r1,
76+
keyUseAuthorizationPolicy = KeyUseAuthorizationPolicy.BiometricCurrentSet,
77+
interactionContext = activity,
78+
)
79+
)
80+
81+
val failure = assertFailsWith<KeyUseAuthorizationException> {
82+
key.signRaw("must not be signed".encodeToByteArray())
83+
}
84+
85+
assertEquals(KeyUseAuthorizationFailure.AuthorizationFailed, failure.failure)
86+
}
87+
88+
private fun <A : FragmentActivity> androidx.test.core.app.ActivityScenario<A>.withActivity(): A {
89+
lateinit var activity: A
90+
onActivity { activity = it }
91+
return activity
92+
}
93+
}

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

Lines changed: 169 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,78 @@
11
package id.walt.crypto
22

3+
import android.os.Build
4+
import android.security.keystore.KeyPermanentlyInvalidatedException
5+
import android.security.keystore.KeyProperties
6+
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
7+
import androidx.fragment.app.FragmentActivity
38
import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer
49
import at.asitplus.signum.indispensable.josef.toJsonWebKey
510
import at.asitplus.signum.supreme.SignatureResult
11+
import at.asitplus.signum.supreme.os.AndroidKeystoreSigner
612
import at.asitplus.signum.supreme.os.AndroidKeyStoreProvider
13+
import at.asitplus.signum.supreme.os.needsAuthentication
14+
import at.asitplus.signum.supreme.os.needsAuthenticationForEveryUse
715
import dev.whyoleg.cryptography.CryptographyProvider
816
import dev.whyoleg.cryptography.providers.jdk.JDK
917
import id.walt.crypto.keys.JwkKeyMeta
1018
import id.walt.crypto.keys.Key
19+
import id.walt.crypto.keys.KeyHardwareBacking
1120
import id.walt.crypto.keys.KeyMeta
1221
import id.walt.crypto.keys.KeyType
22+
import id.walt.crypto.keys.KeyUseAuthorizationAware
23+
import id.walt.crypto.keys.KeyUseAuthorizationException
24+
import id.walt.crypto.keys.KeyUseAuthorizationFailure
25+
import id.walt.crypto.keys.KeyUseAuthorizationPolicy
26+
import id.walt.crypto.keys.KeyUseAuthorizationPrompt
1327
import kotlinx.serialization.json.Json
1428
import kotlinx.serialization.json.JsonElement
1529
import kotlinx.serialization.json.JsonObject
1630
import org.bouncycastle.jce.provider.BouncyCastleProvider
1731
import kotlin.io.encoding.Base64
32+
import kotlin.time.Duration
1833
import kotlin.uuid.Uuid
1934

2035
sealed class AndroidKey : Key() {
2136

2237
class Options(
2338
val kid: String = Uuid.random().toString(),
2439
val keyType: KeyType,
25-
)
40+
val keyUseAuthorizationPolicy: KeyUseAuthorizationPolicy = KeyUseAuthorizationPolicy.None,
41+
val authorizationPrompt: KeyUseAuthorizationPrompt = KeyUseAuthorizationPrompt(),
42+
interactionContext: Any? = null,
43+
) {
44+
internal val interactionContext: FragmentActivity? = interactionContext as? FragmentActivity
45+
}
2646

2747
class Platform internal constructor(
2848
private val options: Options,
2949
override val hasPrivateKey: Boolean = true,
30-
) : AndroidKey() {
50+
) : AndroidKey(), KeyUseAuthorizationAware {
3151

3252
companion object {
3353
suspend fun create(options: Options): Platform {
54+
options.requireSupportedProtectedCombination()
3455
when (val curve = options.keyType.toPlatformKeyStoreCurve()) {
3556
null -> AndroidKeyStoreProvider.createSigningKey(options.kid) {
3657
rsa { }
58+
if (options.keyUseAuthorizationPolicy == KeyUseAuthorizationPolicy.BiometricCurrentSet) {
59+
configureBiometricCurrentSet(options)
60+
}
3761
}.getOrThrow()
3862
else -> AndroidKeyStoreProvider.createSigningKey(options.kid) {
3963
ec { this.curve = curve }
64+
if (options.keyUseAuthorizationPolicy == KeyUseAuthorizationPolicy.BiometricCurrentSet) {
65+
configureBiometricCurrentSet(options)
66+
}
4067
}.getOrThrow()
4168
}
4269
return Platform(options)
4370
}
4471

4572
suspend fun load(options: Options): Platform {
46-
AndroidKeyStoreProvider.getSignerForKey(options.kid).getOrThrow()
73+
options.requireSupportedProtectedCombination()
74+
runCatching { options.loadSigner() }
75+
.getOrElse { throw options.mapPlatformFailure(it) }
4776
return Platform(options)
4877
}
4978

@@ -53,8 +82,23 @@ sealed class AndroidKey : Key() {
5382
}
5483

5584
override val keyType get() = options.keyType
56-
57-
private suspend fun signer() = AndroidKeyStoreProvider.getSignerForKey(options.kid).getOrThrow()
85+
override val keyUseAuthorizationPolicy get() = options.keyUseAuthorizationPolicy
86+
override val isPlatformBacked: Boolean = true
87+
88+
private suspend fun signer(): AndroidKeystoreSigner = runCatching { options.loadSigner() }
89+
.getOrElse { throw options.mapPlatformFailure(it) }
90+
91+
private fun requireSigningInteractionContext() {
92+
if (
93+
options.keyUseAuthorizationPolicy == KeyUseAuthorizationPolicy.BiometricCurrentSet &&
94+
options.interactionContext == null
95+
) {
96+
throw KeyUseAuthorizationException(
97+
failure = KeyUseAuthorizationFailure.InteractionContextUnavailable,
98+
message = "A FragmentActivity interaction context is required for biometric key use",
99+
)
100+
}
101+
}
58102

59103
override suspend fun getKeyId(): String = options.kid
60104

@@ -76,15 +120,24 @@ sealed class AndroidKey : Key() {
76120

77121
override suspend fun signRaw(plaintext: ByteArray, customSignatureAlgorithm: String?): ByteArray {
78122
check(hasPrivateKey) { "Only private key can do signing." }
79-
val result = signer().sign(plaintext)
80-
check(result is SignatureResult.Success) { "Signing failed: $result" }
81-
return result.signature.rawByteArray
123+
requireSigningInteractionContext()
124+
return signer().sign(plaintext).mapPlatformFailure(options).signatureBytesOrThrow(
125+
protectedKeyUse = options.keyUseAuthorizationPolicy != KeyUseAuthorizationPolicy.None,
126+
)
82127
}
83128

84129
override suspend fun signJws(plaintext: ByteArray, headers: Map<String, JsonElement>): String {
85130
check(hasPrivateKey) { "Only private key can do signing." }
131+
requireSigningInteractionContext()
86132
val signer = signer()
87-
return signJwsWithPlatformSigner(options.keyType, plaintext, headers) { data -> signer.sign(data) }
133+
return signJwsWithPlatformSigner(
134+
options.keyType,
135+
plaintext,
136+
headers,
137+
protectedKeyUse = options.keyUseAuthorizationPolicy != KeyUseAuthorizationPolicy.None,
138+
) { data ->
139+
signer.sign(data).mapPlatformFailure(options)
140+
}
88141
}
89142

90143
override suspend fun verifyRaw(
@@ -102,6 +155,23 @@ sealed class AndroidKey : Key() {
102155
override suspend fun getPublicKey(): Key = Platform(options, hasPrivateKey = false)
103156
override suspend fun getPublicKeyRepresentation(): ByteArray = signer().publicKey.encodeToTlv().derEncoded
104157
override suspend fun getMeta(): KeyMeta = JwkKeyMeta(getKeyId())
158+
@Suppress("DEPRECATION")
159+
override suspend fun effectiveHardwareBacking(): KeyHardwareBacking {
160+
val keyInfo = signer().keyInfo
161+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
162+
when (keyInfo.securityLevel) {
163+
KeyProperties.SECURITY_LEVEL_SOFTWARE -> KeyHardwareBacking.Software
164+
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> KeyHardwareBacking.TrustedEnvironment
165+
KeyProperties.SECURITY_LEVEL_STRONGBOX -> KeyHardwareBacking.StrongBox
166+
KeyProperties.SECURITY_LEVEL_UNKNOWN_SECURE -> KeyHardwareBacking.SecureHardware
167+
else -> KeyHardwareBacking.Unknown
168+
}
169+
} else if (keyInfo.isInsideSecureHardware) {
170+
KeyHardwareBacking.SecureHardware
171+
} else {
172+
KeyHardwareBacking.Software
173+
}
174+
}
105175
override suspend fun deleteKey(): Boolean = runCatching {
106176
AndroidKeyStoreProvider.deleteSigningKey(options.kid).getOrThrow()
107177
}.isSuccess
@@ -159,3 +229,93 @@ sealed class AndroidKey : Key() {
159229
override suspend fun deleteKey(): Boolean = delegate.deleteKey()
160230
}
161231
}
232+
233+
private fun at.asitplus.signum.supreme.os.AndroidSigningKeyConfiguration.configureBiometricCurrentSet(
234+
options: AndroidKey.Options,
235+
) {
236+
hardware {
237+
protection {
238+
factors {
239+
biometry = true
240+
biometryWithNewFactors = false
241+
deviceLock = false
242+
}
243+
timeout = Duration.ZERO
244+
}
245+
}
246+
signer {
247+
unlockPrompt {
248+
message = options.authorizationPrompt.message
249+
cancelText = options.authorizationPrompt.cancelText
250+
allowedAuthenticators = BIOMETRIC_STRONG
251+
options.interactionContext?.let { activity = it }
252+
}
253+
}
254+
}
255+
256+
private suspend fun AndroidKey.Options.loadSigner(): AndroidKeystoreSigner {
257+
val signer = AndroidKeyStoreProvider.getSignerForKey(kid) {
258+
unlockPrompt {
259+
message = authorizationPrompt.message
260+
cancelText = authorizationPrompt.cancelText
261+
if (keyUseAuthorizationPolicy == KeyUseAuthorizationPolicy.BiometricCurrentSet) {
262+
allowedAuthenticators = BIOMETRIC_STRONG
263+
}
264+
interactionContext?.let { activity = it }
265+
}
266+
}.getOrThrow()
267+
if (keyUseAuthorizationPolicy == KeyUseAuthorizationPolicy.BiometricCurrentSet) {
268+
val biometricOnly = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
269+
signer.keyInfo.userAuthenticationType == KeyProperties.AUTH_BIOMETRIC_STRONG
270+
} else {
271+
true
272+
}
273+
if (
274+
!signer.needsAuthentication ||
275+
!signer.needsAuthenticationForEveryUse ||
276+
!signer.keyInfo.isInvalidatedByBiometricEnrollment ||
277+
!biometricOnly
278+
) {
279+
throw KeyUseAuthorizationException(
280+
failure = KeyUseAuthorizationFailure.ProtectedKeyInvalidated,
281+
message = "The stored key does not enforce biometric current-set authorization for every use",
282+
)
283+
}
284+
}
285+
return signer
286+
}
287+
288+
private fun AndroidKey.Options.requireSupportedProtectedCombination() {
289+
if (
290+
keyUseAuthorizationPolicy == KeyUseAuthorizationPolicy.BiometricCurrentSet &&
291+
keyType != KeyType.secp256r1
292+
) {
293+
throw KeyUseAuthorizationException(
294+
failure = KeyUseAuthorizationFailure.UnsupportedCombination,
295+
message = "Android biometric current-set authorization is supported only for secp256r1 keys",
296+
)
297+
}
298+
}
299+
300+
private fun AndroidKey.Options.mapPlatformFailure(throwable: Throwable): Throwable {
301+
if (keyUseAuthorizationPolicy == KeyUseAuthorizationPolicy.None) return throwable
302+
val causes = generateSequence(throwable as Throwable?) { it.cause }.toList()
303+
return when {
304+
causes.any { it is KeyPermanentlyInvalidatedException } -> KeyUseAuthorizationException(
305+
failure = KeyUseAuthorizationFailure.ProtectedKeyInvalidated,
306+
message = "The protected key was invalidated",
307+
cause = throwable,
308+
)
309+
causes.any { it is NoSuchElementException } -> KeyUseAuthorizationException(
310+
failure = KeyUseAuthorizationFailure.ProtectedKeyMissing,
311+
message = "The protected key is missing",
312+
cause = throwable,
313+
)
314+
else -> throwable
315+
}
316+
}
317+
318+
private fun SignatureResult<*>.mapPlatformFailure(options: AndroidKey.Options): SignatureResult<*> = when (this) {
319+
is SignatureResult.Error -> SignatureResult.Error(options.mapPlatformFailure(exception))
320+
else -> this
321+
}

0 commit comments

Comments
 (0)