@necessarysdk/react-native 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1157 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +338 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +767 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +959 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +370 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +699 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +134 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/answers.d.ts +6 -0
- package/lib/util/answers.js +16 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +851 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +168 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/answers.ts +15 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- package/vectors/client_server.json +264 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
package com.necessarysdk.reactnative
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.KeyguardManager
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.pm.PackageManager
|
|
7
|
+
import android.hardware.biometrics.BiometricManager
|
|
8
|
+
import android.hardware.biometrics.BiometricPrompt
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.os.CancellationSignal
|
|
11
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
12
|
+
import android.security.keystore.KeyInfo
|
|
13
|
+
import android.security.keystore.KeyPermanentlyInvalidatedException
|
|
14
|
+
import android.security.keystore.KeyProperties
|
|
15
|
+
import android.security.keystore.UserNotAuthenticatedException
|
|
16
|
+
import android.util.Base64
|
|
17
|
+
import java.math.BigInteger
|
|
18
|
+
import java.security.GeneralSecurityException
|
|
19
|
+
import java.security.KeyFactory
|
|
20
|
+
import java.security.KeyPairGenerator
|
|
21
|
+
import java.security.KeyStore
|
|
22
|
+
import java.security.Signature
|
|
23
|
+
import java.security.UnrecoverableKeyException
|
|
24
|
+
import java.security.interfaces.ECPublicKey
|
|
25
|
+
import java.security.spec.ECGenParameterSpec
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Owns the OPTIONAL step-up key: a second non-exportable P-256 key in
|
|
29
|
+
* AndroidKeyStore that cannot sign without a device authentication
|
|
30
|
+
* key approval.
|
|
31
|
+
*
|
|
32
|
+
* The installation key stays unattended for routine proof of possession. This
|
|
33
|
+
* key approves only sensitive operations, so a compromised app process cannot
|
|
34
|
+
* drive it as a silent signing oracle for them.
|
|
35
|
+
*
|
|
36
|
+
* How tightly the hardware binds the authentication depends on the API level
|
|
37
|
+
* level:
|
|
38
|
+
*
|
|
39
|
+
* * Android 11+ (API 30): per-use. Each signature is authorised by its own
|
|
40
|
+
* BiometricPrompt, device credential (passcode) by default.
|
|
41
|
+
* * Android 9/10 with the passcode factor: before API 30 Keystore cannot bind
|
|
42
|
+
* a device credential to one operation, so the key gets a short
|
|
43
|
+
* hardware-enforced validity window. The SDK still asks for the passcode
|
|
44
|
+
* before every signature; the window is the backstop, and it is reported to
|
|
45
|
+
* the server as mode "windowed" so the downgrade is visible, not silent.
|
|
46
|
+
*
|
|
47
|
+
* The factor, mode and window reported to JavaScript are read back from KeyInfo --
|
|
48
|
+
* what the keystore actually enforces -- rather than echoed from the request.
|
|
49
|
+
*/
|
|
50
|
+
class StepUpKeyManager(private val context: Context) {
|
|
51
|
+
companion object {
|
|
52
|
+
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
|
53
|
+
private const val CURVE_NAME = "secp256r1"
|
|
54
|
+
private const val MAX_PAYLOAD_BYTES = 65536
|
|
55
|
+
private const val MAX_WINDOW_SECONDS = 3600
|
|
56
|
+
private const val CONFIRM_CREDENTIAL_REQUEST = 0x5354 // "ST"
|
|
57
|
+
|
|
58
|
+
const val FACTOR_PASSCODE = "passcode"
|
|
59
|
+
const val FACTOR_BIOMETRIC = "biometric"
|
|
60
|
+
const val MODE_PER_USE = "per_use"
|
|
61
|
+
const val MODE_WINDOWED = "windowed"
|
|
62
|
+
|
|
63
|
+
// The hardware window used where per-use passcode is not available
|
|
64
|
+
// (API < 30).
|
|
65
|
+
const val LEGACY_WINDOW_SECONDS = 30
|
|
66
|
+
|
|
67
|
+
// Making and deleting the key run one at a time in the process,
|
|
68
|
+
// whichever runtime asks (as for the installation key).
|
|
69
|
+
private val keyLock = Any()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private class KeyAuth(val factor: String, val mode: String, val windowSeconds: Int)
|
|
73
|
+
|
|
74
|
+
private class PendingConfirm(
|
|
75
|
+
val entry: KeyStore.PrivateKeyEntry,
|
|
76
|
+
val payload: ByteArray,
|
|
77
|
+
val done: (Result<String>) -> Unit,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
private val keyAlias: String =
|
|
81
|
+
"${context.packageName}.device_recognition.stepup_key.v1"
|
|
82
|
+
|
|
83
|
+
// Never holds a key: deleting it says whether the keystore answers
|
|
84
|
+
// (KeyPresence).
|
|
85
|
+
private val probeAlias: String = "$keyAlias.probe"
|
|
86
|
+
|
|
87
|
+
private var pendingConfirm: PendingConfirm? = null
|
|
88
|
+
|
|
89
|
+
// MARK: module operations
|
|
90
|
+
|
|
91
|
+
fun getOrCreateKey(factor: String, mode: String, windowSeconds: Int): Map<String, Any> =
|
|
92
|
+
synchronized(keyLock) { loadOrMakeKey(factor, mode, windowSeconds) }
|
|
93
|
+
|
|
94
|
+
private fun loadOrMakeKey(factor: String, mode: String, windowSeconds: Int): Map<String, Any> {
|
|
95
|
+
requireSupportedAndroid()
|
|
96
|
+
if (factor != FACTOR_PASSCODE && factor != FACTOR_BIOMETRIC) {
|
|
97
|
+
throw InstallationKeyFailure("INVALID_ARGUMENT", "factor must be passcode or biometric.")
|
|
98
|
+
}
|
|
99
|
+
if (mode != MODE_PER_USE && mode != MODE_WINDOWED) {
|
|
100
|
+
throw InstallationKeyFailure("INVALID_ARGUMENT", "mode must be per_use or windowed.")
|
|
101
|
+
}
|
|
102
|
+
if (mode == MODE_WINDOWED && windowSeconds !in 1..MAX_WINDOW_SECONDS) {
|
|
103
|
+
throw InstallationKeyFailure(
|
|
104
|
+
"INVALID_ARGUMENT",
|
|
105
|
+
"A windowed step-up key needs window_seconds between 1 and $MAX_WINDOW_SECONDS.",
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
// An existing key is checked before the screen lock: removing the lock
|
|
109
|
+
// is what kills it, so this is where the app can say so precisely. A
|
|
110
|
+
// key is made only where the keystore, answering, holds none: a lookup
|
|
111
|
+
// that failed reads as "no key", and generating would replace the key
|
|
112
|
+
// the backend holds.
|
|
113
|
+
val keyStore = loadKeyStore()
|
|
114
|
+
val found = presence(keyStore)
|
|
115
|
+
val existing = when (found.answer) {
|
|
116
|
+
KeyPresence.Answer.PRESENT -> liveEntry(keyStore)
|
|
117
|
+
KeyPresence.Answer.ABSENT -> null
|
|
118
|
+
KeyPresence.Answer.UNAVAILABLE -> throw keyUnavailable(found.cause)
|
|
119
|
+
}
|
|
120
|
+
requireDeviceCredential()
|
|
121
|
+
if (factor == FACTOR_BIOMETRIC && Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
|
122
|
+
throw InstallationKeyFailure(
|
|
123
|
+
"STEPUP_UNSUPPORTED",
|
|
124
|
+
"Biometric step-up needs Android 9 (API 28) or newer.",
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
val created = existing == null
|
|
129
|
+
if (created) {
|
|
130
|
+
generateKeyPair(keyStore, factor, mode, windowSeconds)
|
|
131
|
+
}
|
|
132
|
+
val entry = existing ?: liveEntry(keyStore)
|
|
133
|
+
val publicKey = entry.certificate.publicKey as? ECPublicKey
|
|
134
|
+
?: throw InstallationKeyFailure(
|
|
135
|
+
"INVALID_PUBLIC_KEY",
|
|
136
|
+
"AndroidKeyStore did not return an EC public key for the step-up key.",
|
|
137
|
+
)
|
|
138
|
+
val info = keyInfo(entry)
|
|
139
|
+
val auth = describeAuth(info)
|
|
140
|
+
val security = describeSecurity(info)
|
|
141
|
+
|
|
142
|
+
return mapOf(
|
|
143
|
+
"key_alias" to keyAlias,
|
|
144
|
+
"algorithm" to "ES256",
|
|
145
|
+
"signature_format" to "asn1_der",
|
|
146
|
+
"provider" to ANDROID_KEY_STORE,
|
|
147
|
+
"security_level" to security.first,
|
|
148
|
+
"hardware_backed" to security.second,
|
|
149
|
+
"private_key_exportable" to false,
|
|
150
|
+
"created" to created,
|
|
151
|
+
"public_key" to mapOf(
|
|
152
|
+
"kty" to "EC",
|
|
153
|
+
"crv" to "P-256",
|
|
154
|
+
"alg" to "ES256",
|
|
155
|
+
"x" to base64Url(unsignedFixed(publicKey.w.affineX, 32)),
|
|
156
|
+
"y" to base64Url(unsignedFixed(publicKey.w.affineY, 32)),
|
|
157
|
+
),
|
|
158
|
+
"auth" to mapOf(
|
|
159
|
+
"factor" to auth.factor,
|
|
160
|
+
"mode" to auth.mode,
|
|
161
|
+
"window_seconds" to auth.windowSeconds,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Prompts for the device authentication, then signs. Must be called on the
|
|
168
|
+
* main thread; [done] is invoked on the main thread exactly once.
|
|
169
|
+
*
|
|
170
|
+
* [configuredMode] is the app's setting. A windowed key the app asked to be
|
|
171
|
+
* per-use (the API < 30 fallback) prompts every time; a key the app asked
|
|
172
|
+
* to be windowed signs without a prompt while its hardware window is open.
|
|
173
|
+
*/
|
|
174
|
+
fun sign(
|
|
175
|
+
activity: Activity,
|
|
176
|
+
payloadBase64Url: String,
|
|
177
|
+
reason: String,
|
|
178
|
+
configuredMode: String,
|
|
179
|
+
done: (Result<String>) -> Unit,
|
|
180
|
+
) {
|
|
181
|
+
try {
|
|
182
|
+
requireSupportedAndroid()
|
|
183
|
+
if (pendingConfirm != null) {
|
|
184
|
+
throw InstallationKeyFailure(
|
|
185
|
+
"STEPUP_BUSY",
|
|
186
|
+
"A step-up authentication is already in progress.",
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
val payload = decodeBase64Url(payloadBase64Url)
|
|
190
|
+
if (payload.size > MAX_PAYLOAD_BYTES) {
|
|
191
|
+
throw InstallationKeyFailure(
|
|
192
|
+
"PAYLOAD_TOO_LARGE",
|
|
193
|
+
"The step-up payload is larger than $MAX_PAYLOAD_BYTES bytes.",
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
val keyStore = loadKeyStore()
|
|
197
|
+
val found = presence(keyStore)
|
|
198
|
+
when (found.answer) {
|
|
199
|
+
KeyPresence.Answer.PRESENT -> {}
|
|
200
|
+
KeyPresence.Answer.ABSENT -> throw InstallationKeyFailure(
|
|
201
|
+
"STEPUP_KEY_NOT_FOUND",
|
|
202
|
+
"No step-up key exists on this installation.",
|
|
203
|
+
)
|
|
204
|
+
KeyPresence.Answer.UNAVAILABLE -> throw keyUnavailable(found.cause)
|
|
205
|
+
}
|
|
206
|
+
val entry = liveEntry(keyStore)
|
|
207
|
+
val auth = describeAuth(keyInfo(entry))
|
|
208
|
+
|
|
209
|
+
if (auth.mode == MODE_PER_USE) {
|
|
210
|
+
val signer = Signature.getInstance("SHA256withECDSA")
|
|
211
|
+
signer.initSign(entry.privateKey)
|
|
212
|
+
authenticateOperation(activity, signer, auth.factor, reason, payload, done)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (configuredMode == MODE_WINDOWED) {
|
|
217
|
+
try {
|
|
218
|
+
done(Result.success(signNow(entry, payload)))
|
|
219
|
+
return
|
|
220
|
+
} catch (_: UserNotAuthenticatedException) {
|
|
221
|
+
// The window is closed; authenticate below and sign.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
authenticateThenSign(activity, entry, auth.factor, reason, payload, done)
|
|
225
|
+
} catch (error: KeyPermanentlyInvalidatedException) {
|
|
226
|
+
done(Result.failure(discardDeadKey(error)))
|
|
227
|
+
} catch (error: InstallationKeyFailure) {
|
|
228
|
+
done(Result.failure(error))
|
|
229
|
+
} catch (error: GeneralSecurityException) {
|
|
230
|
+
done(
|
|
231
|
+
Result.failure(
|
|
232
|
+
InstallationKeyFailure(
|
|
233
|
+
"STEPUP_SIGNING_FAILED",
|
|
234
|
+
"AndroidKeyStore could not prepare the step-up signature.",
|
|
235
|
+
error,
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Delivers the legacy confirm-credential result. True if it was ours. */
|
|
243
|
+
fun onActivityResult(requestCode: Int, resultCode: Int): Boolean {
|
|
244
|
+
if (requestCode != CONFIRM_CREDENTIAL_REQUEST) {
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
val pending = pendingConfirm ?: return true
|
|
248
|
+
pendingConfirm = null
|
|
249
|
+
if (resultCode == Activity.RESULT_OK) {
|
|
250
|
+
signAndReport(pending.entry, pending.payload, pending.done)
|
|
251
|
+
} else {
|
|
252
|
+
pending.done(
|
|
253
|
+
Result.failure(
|
|
254
|
+
InstallationKeyFailure("STEPUP_CANCELLED", "The passcode prompt was cancelled."),
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
return true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Deletes the key, whatever a lookup says: a lookup that failed reads as
|
|
263
|
+
* "no key", and skipping the deletion then would leave the key in place
|
|
264
|
+
* after a reset. Whether one was there is for the answer only.
|
|
265
|
+
*/
|
|
266
|
+
fun deleteKey(): Boolean = synchronized(keyLock) {
|
|
267
|
+
requireSupportedAndroid()
|
|
268
|
+
val keyStore = loadKeyStore()
|
|
269
|
+
try {
|
|
270
|
+
val existed = keyStore.containsAlias(keyAlias)
|
|
271
|
+
keyStore.deleteEntry(keyAlias)
|
|
272
|
+
if (keyStore.containsAlias(keyAlias)) {
|
|
273
|
+
throw InstallationKeyFailure(
|
|
274
|
+
"KEY_DELETE_FAILED",
|
|
275
|
+
"AndroidKeyStore did not delete the step-up key: it is still present.",
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
existed
|
|
279
|
+
} catch (error: GeneralSecurityException) {
|
|
280
|
+
throw InstallationKeyFailure(
|
|
281
|
+
"KEY_DELETE_FAILED",
|
|
282
|
+
"AndroidKeyStore could not delete the step-up key.",
|
|
283
|
+
error,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// MARK: authentication
|
|
289
|
+
|
|
290
|
+
/** Per-use: the prompt authorises this one Signature object. */
|
|
291
|
+
private fun authenticateOperation(
|
|
292
|
+
activity: Activity,
|
|
293
|
+
signer: Signature,
|
|
294
|
+
factor: String,
|
|
295
|
+
reason: String,
|
|
296
|
+
payload: ByteArray,
|
|
297
|
+
done: (Result<String>) -> Unit,
|
|
298
|
+
) {
|
|
299
|
+
val prompt = buildPrompt(activity, factor, reason, done)
|
|
300
|
+
prompt.authenticate(
|
|
301
|
+
BiometricPrompt.CryptoObject(signer),
|
|
302
|
+
CancellationSignal(),
|
|
303
|
+
activity.mainExecutor,
|
|
304
|
+
object : BiometricPrompt.AuthenticationCallback() {
|
|
305
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
306
|
+
val authorised = result.cryptoObject?.signature ?: signer
|
|
307
|
+
// Any failure here is told, a keystore's ProviderException
|
|
308
|
+
// among them: unanswered, this runs on the main thread, and
|
|
309
|
+
// an exception would end the app with the approval waiting.
|
|
310
|
+
val outcome: Result<String> = try {
|
|
311
|
+
authorised.update(payload)
|
|
312
|
+
Result.success(base64Url(authorised.sign()))
|
|
313
|
+
} catch (error: Exception) {
|
|
314
|
+
Result.failure(signingFailed(error))
|
|
315
|
+
}
|
|
316
|
+
done(outcome)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
320
|
+
done(Result.failure(promptError(errorCode, errString)))
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Windowed: authenticate first, then sign inside the hardware window. */
|
|
327
|
+
private fun authenticateThenSign(
|
|
328
|
+
activity: Activity,
|
|
329
|
+
entry: KeyStore.PrivateKeyEntry,
|
|
330
|
+
factor: String,
|
|
331
|
+
reason: String,
|
|
332
|
+
payload: ByteArray,
|
|
333
|
+
done: (Result<String>) -> Unit,
|
|
334
|
+
) {
|
|
335
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
336
|
+
val prompt = buildPrompt(activity, factor, reason, done)
|
|
337
|
+
prompt.authenticate(
|
|
338
|
+
CancellationSignal(),
|
|
339
|
+
activity.mainExecutor,
|
|
340
|
+
object : BiometricPrompt.AuthenticationCallback() {
|
|
341
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
342
|
+
signAndReport(entry, payload, done)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
346
|
+
done(Result.failure(promptError(errorCode, errString)))
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// API < 30: the system confirm-credential screen (PIN / pattern /
|
|
354
|
+
// password). A successful confirmation opens the key's window.
|
|
355
|
+
val keyguard = context.getSystemService(KeyguardManager::class.java)
|
|
356
|
+
@Suppress("DEPRECATION")
|
|
357
|
+
val intent = keyguard?.createConfirmDeviceCredentialIntent(
|
|
358
|
+
"Approve sensitive operation",
|
|
359
|
+
reason,
|
|
360
|
+
) ?: throw InstallationKeyFailure(
|
|
361
|
+
"STEPUP_NO_DEVICE_CREDENTIAL",
|
|
362
|
+
"Set a screen lock (PIN, pattern or password) to use step-up.",
|
|
363
|
+
)
|
|
364
|
+
pendingConfirm = PendingConfirm(entry, payload, done)
|
|
365
|
+
try {
|
|
366
|
+
activity.startActivityForResult(intent, CONFIRM_CREDENTIAL_REQUEST)
|
|
367
|
+
} catch (error: RuntimeException) {
|
|
368
|
+
// The confirmation never opened: nothing may wait for it, or every
|
|
369
|
+
// later approval would answer STEPUP_BUSY. The caller says why.
|
|
370
|
+
pendingConfirm = null
|
|
371
|
+
throw error
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The activity that showed the confirm-credential screen is gone for good
|
|
377
|
+
* (not recreated for a configuration change): its answer will never come,
|
|
378
|
+
* so the approval ends as a cancelled one, and the next can be asked.
|
|
379
|
+
*/
|
|
380
|
+
fun cancelPendingConfirmation() {
|
|
381
|
+
val pending = pendingConfirm ?: return
|
|
382
|
+
pendingConfirm = null
|
|
383
|
+
pending.done(
|
|
384
|
+
Result.failure(
|
|
385
|
+
InstallationKeyFailure(
|
|
386
|
+
"STEPUP_CANCELLED",
|
|
387
|
+
"The passcode prompt was cancelled: the screen that asked for it is gone.",
|
|
388
|
+
),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private fun buildPrompt(
|
|
394
|
+
activity: Activity,
|
|
395
|
+
factor: String,
|
|
396
|
+
reason: String,
|
|
397
|
+
done: (Result<String>) -> Unit,
|
|
398
|
+
): BiometricPrompt {
|
|
399
|
+
val builder = BiometricPrompt.Builder(activity)
|
|
400
|
+
.setTitle("Approve sensitive operation")
|
|
401
|
+
.setDescription(reason)
|
|
402
|
+
val allowsCredential = factor == FACTOR_PASSCODE &&
|
|
403
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
|
404
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
405
|
+
builder.setAllowedAuthenticators(
|
|
406
|
+
if (allowsCredential) {
|
|
407
|
+
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
|
408
|
+
} else {
|
|
409
|
+
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
if (!allowsCredential) {
|
|
414
|
+
// A biometric-only prompt must offer its own way out; a
|
|
415
|
+
// device-credential prompt must not (the system provides one).
|
|
416
|
+
builder.setNegativeButton("Cancel", activity.mainExecutor) { _, _ ->
|
|
417
|
+
done(
|
|
418
|
+
Result.failure(
|
|
419
|
+
InstallationKeyFailure("STEPUP_CANCELLED", "The biometric prompt was cancelled."),
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return builder.build()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private fun promptError(errorCode: Int, errString: CharSequence): InstallationKeyFailure {
|
|
428
|
+
val cancelled = errorCode == BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED ||
|
|
429
|
+
errorCode == BiometricPrompt.BIOMETRIC_ERROR_CANCELED
|
|
430
|
+
return InstallationKeyFailure(
|
|
431
|
+
if (cancelled) "STEPUP_CANCELLED" else "STEPUP_AUTH_FAILED",
|
|
432
|
+
"Step-up authentication did not complete: $errString (code $errorCode)",
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// MARK: signing
|
|
437
|
+
|
|
438
|
+
private fun signNow(entry: KeyStore.PrivateKeyEntry, payload: ByteArray): String {
|
|
439
|
+
val signer = Signature.getInstance("SHA256withECDSA")
|
|
440
|
+
signer.initSign(entry.privateKey)
|
|
441
|
+
signer.update(payload)
|
|
442
|
+
return base64Url(signer.sign())
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private fun signAndReport(
|
|
446
|
+
entry: KeyStore.PrivateKeyEntry,
|
|
447
|
+
payload: ByteArray,
|
|
448
|
+
done: (Result<String>) -> Unit,
|
|
449
|
+
) {
|
|
450
|
+
// Runs on the main thread after the person answered: every failure is
|
|
451
|
+
// told, as in authenticateOperation.
|
|
452
|
+
val outcome: Result<String> = try {
|
|
453
|
+
Result.success(signNow(entry, payload))
|
|
454
|
+
} catch (error: KeyPermanentlyInvalidatedException) {
|
|
455
|
+
Result.failure(discardDeadKey(error))
|
|
456
|
+
} catch (error: UserNotAuthenticatedException) {
|
|
457
|
+
Result.failure(
|
|
458
|
+
InstallationKeyFailure(
|
|
459
|
+
"STEPUP_AUTH_FAILED",
|
|
460
|
+
"The keystore did not accept the authentication for the step-up key.",
|
|
461
|
+
error,
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
} catch (error: Exception) {
|
|
465
|
+
Result.failure(signingFailed(error))
|
|
466
|
+
}
|
|
467
|
+
done(outcome)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Deletes a step-up key that can never sign again and reports it. The
|
|
472
|
+
* installation must then re-enrol: the server never accepts a replacement
|
|
473
|
+
* step-up key for an existing installation.
|
|
474
|
+
*/
|
|
475
|
+
private fun discardDeadKey(cause: Throwable): InstallationKeyFailure = synchronized(keyLock) {
|
|
476
|
+
// Say whether the removal actually happened: on an Android 9 phone a
|
|
477
|
+
// dead key's alias survived deleteEntry while the screen lock was off.
|
|
478
|
+
val removal = try {
|
|
479
|
+
val keyStore = loadKeyStore()
|
|
480
|
+
keyStore.deleteEntry(keyAlias)
|
|
481
|
+
if (keyStore.containsAlias(keyAlias)) "could not be removed (alias still present)" else "has been removed"
|
|
482
|
+
} catch (error: Exception) {
|
|
483
|
+
// Not the key's cause: the removal's own failure, said in full here.
|
|
484
|
+
"could not be removed (${Causes.chain(error)})"
|
|
485
|
+
}
|
|
486
|
+
InstallationKeyFailure(
|
|
487
|
+
"STEPUP_KEY_INVALIDATED",
|
|
488
|
+
"The step-up key was invalidated (the screen lock or enrolled biometrics changed) " +
|
|
489
|
+
"and $removal. Re-enrol the installation to get a new one.",
|
|
490
|
+
cause,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private fun signingFailed(error: Throwable) = InstallationKeyFailure(
|
|
495
|
+
"STEPUP_SIGNING_FAILED",
|
|
496
|
+
"AndroidKeyStore could not sign with the step-up key.",
|
|
497
|
+
error,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
// MARK: key material
|
|
501
|
+
|
|
502
|
+
private fun generateKeyPair(
|
|
503
|
+
keyStore: KeyStore,
|
|
504
|
+
factor: String,
|
|
505
|
+
mode: String,
|
|
506
|
+
windowSeconds: Int,
|
|
507
|
+
) {
|
|
508
|
+
val canTryStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
|
509
|
+
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
|
510
|
+
if (canTryStrongBox) {
|
|
511
|
+
try {
|
|
512
|
+
generateKeyPair(factor, mode, windowSeconds, preferStrongBox = true)
|
|
513
|
+
return
|
|
514
|
+
} catch (_: Exception) {
|
|
515
|
+
// Same fallback as the installation key: remove any partial
|
|
516
|
+
// alias and retry with the regular AndroidKeyStore provider.
|
|
517
|
+
try {
|
|
518
|
+
if (keyStore.containsAlias(keyAlias)) {
|
|
519
|
+
keyStore.deleteEntry(keyAlias)
|
|
520
|
+
}
|
|
521
|
+
} catch (_: Exception) {
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
generateKeyPair(factor, mode, windowSeconds, preferStrongBox = false)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private fun generateKeyPair(
|
|
529
|
+
factor: String,
|
|
530
|
+
mode: String,
|
|
531
|
+
windowSeconds: Int,
|
|
532
|
+
preferStrongBox: Boolean,
|
|
533
|
+
) {
|
|
534
|
+
try {
|
|
535
|
+
val builder = KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_SIGN)
|
|
536
|
+
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE_NAME))
|
|
537
|
+
.setDigests(KeyProperties.DIGEST_SHA256)
|
|
538
|
+
.setUserAuthenticationRequired(true)
|
|
539
|
+
|
|
540
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
541
|
+
builder.setUserAuthenticationParameters(
|
|
542
|
+
if (mode == MODE_WINDOWED) windowSeconds else 0,
|
|
543
|
+
if (factor == FACTOR_BIOMETRIC) {
|
|
544
|
+
KeyProperties.AUTH_BIOMETRIC_STRONG
|
|
545
|
+
} else {
|
|
546
|
+
KeyProperties.AUTH_DEVICE_CREDENTIAL
|
|
547
|
+
},
|
|
548
|
+
)
|
|
549
|
+
} else if (factor == FACTOR_PASSCODE) {
|
|
550
|
+
// No per-operation device credential before API 30, so a short
|
|
551
|
+
// hardware window is the strongest binding.
|
|
552
|
+
@Suppress("DEPRECATION")
|
|
553
|
+
builder.setUserAuthenticationValidityDurationSeconds(
|
|
554
|
+
if (mode == MODE_WINDOWED) windowSeconds else LEGACY_WINDOW_SECONDS,
|
|
555
|
+
)
|
|
556
|
+
} else if (mode == MODE_WINDOWED) {
|
|
557
|
+
@Suppress("DEPRECATION")
|
|
558
|
+
builder.setUserAuthenticationValidityDurationSeconds(windowSeconds)
|
|
559
|
+
}
|
|
560
|
+
// else: biometric per-use before API 30 keeps the default (-1),
|
|
561
|
+
// which demands a biometric for every operation.
|
|
562
|
+
|
|
563
|
+
if (factor == FACTOR_BIOMETRIC && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
564
|
+
builder.setInvalidatedByBiometricEnrollment(true)
|
|
565
|
+
}
|
|
566
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && preferStrongBox) {
|
|
567
|
+
builder.setIsStrongBoxBacked(true)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
val generator = KeyPairGenerator.getInstance(
|
|
571
|
+
KeyProperties.KEY_ALGORITHM_EC,
|
|
572
|
+
ANDROID_KEY_STORE,
|
|
573
|
+
)
|
|
574
|
+
generator.initialize(builder.build())
|
|
575
|
+
generator.generateKeyPair()
|
|
576
|
+
} catch (error: Exception) {
|
|
577
|
+
throw InstallationKeyFailure(
|
|
578
|
+
"STEPUP_KEY_GENERATION_FAILED",
|
|
579
|
+
"AndroidKeyStore could not create the step-up key.",
|
|
580
|
+
error,
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private fun requireSupportedAndroid() {
|
|
586
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
587
|
+
throw InstallationKeyFailure(
|
|
588
|
+
"UNSUPPORTED_ANDROID_VERSION",
|
|
589
|
+
"Step-up requires Android 6.0 (API 23) or newer.",
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private fun requireDeviceCredential() {
|
|
595
|
+
val keyguard = context.getSystemService(KeyguardManager::class.java)
|
|
596
|
+
if (keyguard == null || !keyguard.isDeviceSecure) {
|
|
597
|
+
throw InstallationKeyFailure(
|
|
598
|
+
"STEPUP_NO_DEVICE_CREDENTIAL",
|
|
599
|
+
"Set a screen lock (PIN, pattern or password) to enable step-up.",
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private fun loadKeyStore(): KeyStore {
|
|
605
|
+
try {
|
|
606
|
+
return KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
|
607
|
+
} catch (error: GeneralSecurityException) {
|
|
608
|
+
throw InstallationKeyFailure("KEYSTORE_UNAVAILABLE", "AndroidKeyStore is unavailable.", error)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Loads the stored step-up key. Removing the screen lock (or, for a
|
|
614
|
+
* biometric key, changing the enrolled biometrics) invalidates an
|
|
615
|
+
* auth-bound key: Android says so when a signature starts
|
|
616
|
+
* (KeyPermanentlyInvalidatedException), and only then is the key deleted
|
|
617
|
+
* and STEPUP_KEY_INVALIDATED reported. A lookup that cannot establish
|
|
618
|
+
* whether the key is usable keeps it: STEPUP_KEY_UNAVAILABLE, retryable;
|
|
619
|
+
* any other keystore failure: KEY_LOOKUP_FAILED, the key kept
|
|
620
|
+
* (StepUpKeyEntry decides).
|
|
621
|
+
*/
|
|
622
|
+
private fun liveEntry(keyStore: KeyStore): KeyStore.PrivateKeyEntry {
|
|
623
|
+
// What the lookups find is decided by StepUpKeyEntry: only Android's
|
|
624
|
+
// own word that the key is dead deletes it. An alias that outlived its
|
|
625
|
+
// private key on an Android 9 phone (isKeyEntry false, getEntry
|
|
626
|
+
// throwing UnsupportedOperationException) used to be deleted here, as
|
|
627
|
+
// did every lookup that could not say -- on Android 9 a binder error
|
|
628
|
+
// also reads as isKeyEntry false. Those keys are now kept.
|
|
629
|
+
var entry: KeyStore.PrivateKeyEntry? = null
|
|
630
|
+
var cause: Throwable? = null
|
|
631
|
+
val lookup = if (!keyStore.isKeyEntry(keyAlias)) {
|
|
632
|
+
StepUpKeyEntry.Lookup.NOT_KEY_ENTRY
|
|
633
|
+
} else {
|
|
634
|
+
try {
|
|
635
|
+
entry = keyStore.getEntry(keyAlias, null) as? KeyStore.PrivateKeyEntry
|
|
636
|
+
if (entry == null) StepUpKeyEntry.Lookup.NOT_PRIVATE_KEY else StepUpKeyEntry.Lookup.PRIVATE_KEY
|
|
637
|
+
} catch (error: UnrecoverableKeyException) {
|
|
638
|
+
cause = error
|
|
639
|
+
StepUpKeyEntry.Lookup.UNRECOVERABLE
|
|
640
|
+
} catch (error: UnsupportedOperationException) {
|
|
641
|
+
cause = error
|
|
642
|
+
StepUpKeyEntry.Lookup.UNSUPPORTED
|
|
643
|
+
} catch (error: GeneralSecurityException) {
|
|
644
|
+
cause = error
|
|
645
|
+
StepUpKeyEntry.Lookup.OTHER_FAILURE
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
var start = StepUpKeyEntry.SignStart.NOT_TRIED
|
|
649
|
+
val loaded = entry
|
|
650
|
+
if (loaded != null) {
|
|
651
|
+
start = try {
|
|
652
|
+
Signature.getInstance("SHA256withECDSA").initSign(loaded.privateKey)
|
|
653
|
+
StepUpKeyEntry.SignStart.STARTED
|
|
654
|
+
} catch (error: KeyPermanentlyInvalidatedException) {
|
|
655
|
+
cause = error
|
|
656
|
+
StepUpKeyEntry.SignStart.PERMANENTLY_INVALIDATED
|
|
657
|
+
} catch (_: UserNotAuthenticatedException) {
|
|
658
|
+
StepUpKeyEntry.SignStart.NEEDS_AUTHENTICATION
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return when (StepUpKeyEntry.verdict(lookup, start)) {
|
|
662
|
+
StepUpKeyEntry.Verdict.USABLE -> loaded!!
|
|
663
|
+
StepUpKeyEntry.Verdict.INVALIDATED -> throw discardDeadKey(cause!!)
|
|
664
|
+
StepUpKeyEntry.Verdict.UNAVAILABLE -> throw InstallationKeyFailure(
|
|
665
|
+
"STEPUP_KEY_UNAVAILABLE",
|
|
666
|
+
"AndroidKeyStore could not establish whether the step-up key is usable " +
|
|
667
|
+
"(lookup: ${lookup.name.lowercase()}); the key was kept. Try again; if this " +
|
|
668
|
+
"persists, re-enrol to replace it.",
|
|
669
|
+
cause,
|
|
670
|
+
)
|
|
671
|
+
StepUpKeyEntry.Verdict.LOOKUP_FAILED -> throw InstallationKeyFailure(
|
|
672
|
+
"KEY_LOOKUP_FAILED",
|
|
673
|
+
"AndroidKeyStore could not load the step-up key.",
|
|
674
|
+
cause,
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private fun presence(keyStore: KeyStore): KeyPresence.Found =
|
|
680
|
+
KeyPresence.of(object : KeyPresence.Keystore {
|
|
681
|
+
override fun containsKey() = keyStore.containsAlias(keyAlias)
|
|
682
|
+
override fun deleteProbe() = keyStore.deleteEntry(probeAlias)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
private fun keyUnavailable(cause: Throwable?) = InstallationKeyFailure(
|
|
686
|
+
"STEPUP_KEY_UNAVAILABLE",
|
|
687
|
+
"AndroidKeyStore could not be reached for the step-up key; the key was kept, never " +
|
|
688
|
+
"replaced. Try again; if this persists, re-enrol to replace it.",
|
|
689
|
+
cause,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
private fun keyInfo(entry: KeyStore.PrivateKeyEntry): KeyInfo {
|
|
693
|
+
try {
|
|
694
|
+
val keyFactory = KeyFactory.getInstance(entry.privateKey.algorithm, ANDROID_KEY_STORE)
|
|
695
|
+
return keyFactory.getKeySpec(entry.privateKey, KeyInfo::class.java)
|
|
696
|
+
} catch (error: GeneralSecurityException) {
|
|
697
|
+
throw InstallationKeyFailure(
|
|
698
|
+
"KEY_LOOKUP_FAILED",
|
|
699
|
+
"AndroidKeyStore could not describe the step-up key.",
|
|
700
|
+
error,
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** What the keystore enforces for this key, read back from KeyInfo. */
|
|
706
|
+
private fun describeAuth(info: KeyInfo): KeyAuth {
|
|
707
|
+
val duration = info.userAuthenticationValidityDurationSeconds
|
|
708
|
+
val mode = if (duration > 0) MODE_WINDOWED else MODE_PER_USE
|
|
709
|
+
val factor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
710
|
+
if (info.userAuthenticationType and KeyProperties.AUTH_DEVICE_CREDENTIAL != 0) {
|
|
711
|
+
FACTOR_PASSCODE
|
|
712
|
+
} else {
|
|
713
|
+
FACTOR_BIOMETRIC
|
|
714
|
+
}
|
|
715
|
+
} else {
|
|
716
|
+
// KeyInfo has no auth type before API 30. A per-use key can then
|
|
717
|
+
// only be unlocked by a biometric; a windowed key by any lock-screen
|
|
718
|
+
// authentication, and the SDK prompts for the passcode.
|
|
719
|
+
if (mode == MODE_PER_USE) FACTOR_BIOMETRIC else FACTOR_PASSCODE
|
|
720
|
+
}
|
|
721
|
+
return KeyAuth(factor, mode, if (duration > 0) duration else 0)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private fun describeSecurity(info: KeyInfo): Pair<String, Boolean> {
|
|
725
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
726
|
+
when (info.securityLevel) {
|
|
727
|
+
KeyProperties.SECURITY_LEVEL_STRONGBOX -> "strongbox" to true
|
|
728
|
+
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "trusted_execution_environment" to true
|
|
729
|
+
KeyProperties.SECURITY_LEVEL_SOFTWARE -> "software" to false
|
|
730
|
+
else -> "unknown" to false
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
@Suppress("DEPRECATION")
|
|
734
|
+
if (info.isInsideSecureHardware) "secure_hardware" to true else "software" to false
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// MARK: encoding (mirrors InstallationKeyManager)
|
|
739
|
+
|
|
740
|
+
private fun unsignedFixed(value: BigInteger, size: Int): ByteArray {
|
|
741
|
+
var raw = value.toByteArray()
|
|
742
|
+
if (raw.size == size + 1 && raw[0] == 0.toByte()) {
|
|
743
|
+
raw = raw.copyOfRange(1, raw.size)
|
|
744
|
+
}
|
|
745
|
+
if (raw.size > size) {
|
|
746
|
+
throw InstallationKeyFailure("INVALID_PUBLIC_KEY", "The EC coordinate is larger than P-256 permits.")
|
|
747
|
+
}
|
|
748
|
+
return ByteArray(size).also { output ->
|
|
749
|
+
System.arraycopy(raw, 0, output, size - raw.size, raw.size)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private fun base64Url(value: ByteArray): String {
|
|
754
|
+
return Base64.encodeToString(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private fun decodeBase64Url(value: String): ByteArray {
|
|
758
|
+
if (value.isBlank()) {
|
|
759
|
+
throw InstallationKeyFailure("INVALID_PAYLOAD", "The step-up payload is empty.")
|
|
760
|
+
}
|
|
761
|
+
return try {
|
|
762
|
+
Base64.decode(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
|
763
|
+
} catch (error: IllegalArgumentException) {
|
|
764
|
+
throw InstallationKeyFailure("INVALID_PAYLOAD", "The step-up payload is not valid base64url.", error)
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|