react-native-wallet-keystore 0.1.0
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/LICENSE +20 -0
- package/README.md +376 -0
- package/WalletKeystore.podspec +29 -0
- package/android/build.gradle +62 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/walletkeystore/Secp256k1.kt +158 -0
- package/android/src/main/java/com/walletkeystore/WalletKeystoreCrypto.kt +233 -0
- package/android/src/main/java/com/walletkeystore/WalletKeystoreModule.kt +627 -0
- package/android/src/main/java/com/walletkeystore/WalletKeystorePackage.kt +31 -0
- package/ios/WalletKeystore.h +5 -0
- package/ios/WalletKeystore.mm +895 -0
- package/lib/module/NativeWalletKeystore.js +15 -0
- package/lib/module/NativeWalletKeystore.js.map +1 -0
- package/lib/module/errors.js +57 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/index.js +9 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/keystore.js +220 -0
- package/lib/module/keystore.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/viem.js +65 -0
- package/lib/module/viem.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeWalletKeystore.d.ts +26 -0
- package/lib/typescript/src/NativeWalletKeystore.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +29 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/keystore.d.ts +100 -0
- package/lib/typescript/src/keystore.d.ts.map +1 -0
- package/lib/typescript/src/viem.d.ts +24 -0
- package/lib/typescript/src/viem.d.ts.map +1 -0
- package/package.json +210 -0
- package/src/NativeWalletKeystore.ts +43 -0
- package/src/errors.ts +94 -0
- package/src/index.tsx +6 -0
- package/src/keystore.ts +317 -0
- package/src/viem.ts +99 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
package com.walletkeystore
|
|
2
|
+
|
|
3
|
+
import android.content.pm.PackageManager
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import androidx.biometric.BiometricManager
|
|
6
|
+
import androidx.biometric.BiometricPrompt
|
|
7
|
+
import androidx.core.content.ContextCompat
|
|
8
|
+
import androidx.fragment.app.FragmentActivity
|
|
9
|
+
import com.facebook.react.bridge.Promise
|
|
10
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
11
|
+
import com.facebook.react.bridge.UiThreadUtil
|
|
12
|
+
import java.math.BigInteger
|
|
13
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
14
|
+
import javax.crypto.Cipher
|
|
15
|
+
|
|
16
|
+
class WalletKeystoreModule(reactContext: ReactApplicationContext) :
|
|
17
|
+
NativeWalletKeystoreSpec(reactContext) {
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where an operation's result goes. Signing reuses the storage operations and
|
|
21
|
+
* needs to intercept their result, and this avoids hand-rolling a `Promise`
|
|
22
|
+
* stand-in that would drift from React Native's interface.
|
|
23
|
+
*/
|
|
24
|
+
private interface Settler {
|
|
25
|
+
fun resolve(value: Any?)
|
|
26
|
+
fun reject(code: String, message: String)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Settles exactly once. BiometricPrompt can deliver a terminal callback while
|
|
31
|
+
* an earlier failure is still unwinding, and settling twice throws.
|
|
32
|
+
*/
|
|
33
|
+
private class PromiseGuard(private val promise: Promise) : Settler {
|
|
34
|
+
private val settled = AtomicBoolean(false)
|
|
35
|
+
|
|
36
|
+
override fun resolve(value: Any?) {
|
|
37
|
+
if (settled.compareAndSet(false, true)) promise.resolve(value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override fun reject(code: String, message: String) {
|
|
41
|
+
if (settled.compareAndSet(false, true)) promise.reject(code, message)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Routes a nested operation's outcome back into the caller's own handling. */
|
|
46
|
+
private class Relay(
|
|
47
|
+
private val onResolve: (Any?) -> Unit,
|
|
48
|
+
private val onReject: (String, String) -> Unit,
|
|
49
|
+
) : Settler {
|
|
50
|
+
override fun resolve(value: Any?) = onResolve(value)
|
|
51
|
+
override fun reject(code: String, message: String) = onReject(code, message)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Authentication
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
override fun getBiometryType(promise: Promise) {
|
|
59
|
+
val pm = reactApplicationContext.packageManager
|
|
60
|
+
|
|
61
|
+
// PackageManager reports hardware presence only; Android has no API for
|
|
62
|
+
// which modality is enrolled. Several present means we cannot attribute.
|
|
63
|
+
val hasFingerprint = pm.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
|
64
|
+
val hasFace =
|
|
65
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
|
66
|
+
pm.hasSystemFeature(PackageManager.FEATURE_FACE)
|
|
67
|
+
val hasIris =
|
|
68
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
|
69
|
+
pm.hasSystemFeature(PackageManager.FEATURE_IRIS)
|
|
70
|
+
|
|
71
|
+
val present = listOf(
|
|
72
|
+
hasFingerprint to "fingerprint",
|
|
73
|
+
hasFace to "face",
|
|
74
|
+
hasIris to "iris"
|
|
75
|
+
).filter { it.first }.map { it.second }
|
|
76
|
+
|
|
77
|
+
promise.resolve(
|
|
78
|
+
when {
|
|
79
|
+
present.isEmpty() -> "none"
|
|
80
|
+
present.size == 1 -> present.first()
|
|
81
|
+
else -> "biometric"
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override fun authenticate(reason: String, policy: String, promise: Promise) {
|
|
87
|
+
val guard = PromiseGuard(promise)
|
|
88
|
+
|
|
89
|
+
if (reason.isBlank()) {
|
|
90
|
+
guard.reject(CODE_UNKNOWN, "A non-empty `reason` is required to authenticate.")
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
val activity = reactApplicationContext.currentActivity
|
|
95
|
+
if (activity !is FragmentActivity) {
|
|
96
|
+
guard.reject(
|
|
97
|
+
CODE_NOT_AVAILABLE,
|
|
98
|
+
"A foreground FragmentActivity is required to show the biometric prompt."
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
val authenticators = authenticatorsFor(policy)
|
|
104
|
+
val status = BiometricManager.from(reactApplicationContext).canAuthenticate(authenticators)
|
|
105
|
+
if (status != BiometricManager.BIOMETRIC_SUCCESS) {
|
|
106
|
+
guard.reject(mapAvailability(status), availabilityMessage(status, authenticators))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// BiometricPrompt must be constructed and shown on the main thread. The
|
|
111
|
+
// authentication itself runs off it, and the callback returns here.
|
|
112
|
+
UiThreadUtil.runOnUiThread {
|
|
113
|
+
try {
|
|
114
|
+
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
|
115
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
116
|
+
guard.resolve(true)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
120
|
+
guard.reject(mapAuthError(errorCode), errString.toString())
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// onAuthenticationFailed is deliberately not overridden: it fires per
|
|
124
|
+
// rejected attempt while the prompt stays up, so settling there would
|
|
125
|
+
// end the flow on the user's first fumble.
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
|
|
129
|
+
.authenticate(buildPromptInfo(reason, authenticators))
|
|
130
|
+
} catch (e: Exception) {
|
|
131
|
+
guard.reject(CODE_UNKNOWN, e.message ?: "Failed to present the biometric prompt.")
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Secret storage
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
override fun storeSecret(
|
|
141
|
+
keyId: String,
|
|
142
|
+
secretHex: String,
|
|
143
|
+
policy: String,
|
|
144
|
+
invalidation: String,
|
|
145
|
+
promise: Promise
|
|
146
|
+
) = storeSecretInto(keyId, secretHex, policy, invalidation, PromiseGuard(promise))
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Unlike iOS, storing prompts here: the wrapping key is symmetric, and
|
|
150
|
+
* `setUserAuthenticationRequired(true)` governs every use of it. Silent
|
|
151
|
+
* storage would mean dropping that requirement entirely.
|
|
152
|
+
*/
|
|
153
|
+
private fun storeSecretInto(
|
|
154
|
+
keyId: String,
|
|
155
|
+
secretHex: String,
|
|
156
|
+
policy: String,
|
|
157
|
+
invalidation: String,
|
|
158
|
+
settler: Settler
|
|
159
|
+
) {
|
|
160
|
+
val secret = WalletKeystoreCrypto.fromHex(secretHex)
|
|
161
|
+
if (secret == null) {
|
|
162
|
+
settler.reject(CODE_UNKNOWN, "`secretHex` must be a non-empty hex string.")
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Overwriting a wallet key has to be deliberate.
|
|
167
|
+
if (WalletKeystoreCrypto.hasRecord(reactApplicationContext, keyId) ||
|
|
168
|
+
WalletKeystoreCrypto.hasKey(keyId)
|
|
169
|
+
) {
|
|
170
|
+
settler.reject(CODE_KEY_ALREADY_EXISTS, "A secret is already stored under this keyId.")
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
val generated = try {
|
|
175
|
+
WalletKeystoreCrypto.generateKey(keyId, policy, invalidation)
|
|
176
|
+
} catch (e: Exception) {
|
|
177
|
+
settler.reject(CODE_STORAGE_ERROR, e.message ?: "Could not create the wrapping key.")
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
val cipher = try {
|
|
182
|
+
WalletKeystoreCrypto.encryptCipher(generated.key)
|
|
183
|
+
} catch (e: Exception) {
|
|
184
|
+
WalletKeystoreCrypto.deleteKey(keyId)
|
|
185
|
+
settler.reject(classify(e), e.message ?: "Could not initialize encryption.")
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
withPrompt(
|
|
190
|
+
settler,
|
|
191
|
+
reason = "Store your wallet key",
|
|
192
|
+
policy = policy,
|
|
193
|
+
cipher = cipher,
|
|
194
|
+
onAuthenticated = { authenticated -> finishStore(settler, keyId, secret, authenticated) },
|
|
195
|
+
onSetupFailure = { WalletKeystoreCrypto.deleteKey(keyId) }
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun finishStore(
|
|
200
|
+
settler: Settler,
|
|
201
|
+
keyId: String,
|
|
202
|
+
secret: ByteArray,
|
|
203
|
+
cipher: Cipher
|
|
204
|
+
) {
|
|
205
|
+
try {
|
|
206
|
+
val ciphertext = cipher.doFinal(secret)
|
|
207
|
+
WalletKeystoreCrypto.writeRecord(reactApplicationContext, keyId, cipher.iv, ciphertext)
|
|
208
|
+
settler.resolve(null)
|
|
209
|
+
} catch (e: Exception) {
|
|
210
|
+
// Never leave a key behind with no ciphertext — the id would look taken
|
|
211
|
+
// forever and storeSecret would keep rejecting KEY_ALREADY_EXISTS.
|
|
212
|
+
WalletKeystoreCrypto.deleteKey(keyId)
|
|
213
|
+
WalletKeystoreCrypto.deleteRecord(reactApplicationContext, keyId)
|
|
214
|
+
settler.reject(classify(e), e.message ?: "Could not encrypt the secret.")
|
|
215
|
+
} finally {
|
|
216
|
+
secret.fill(0)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
override fun getSecret(keyId: String, reason: String, promise: Promise) =
|
|
221
|
+
getSecretInto(keyId, reason, PromiseGuard(promise))
|
|
222
|
+
|
|
223
|
+
private fun getSecretInto(keyId: String, reason: String, settler: Settler) {
|
|
224
|
+
if (reason.isBlank()) {
|
|
225
|
+
settler.reject(CODE_UNKNOWN, "A non-empty `reason` is required to read a secret.")
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
val record = WalletKeystoreCrypto.readRecord(reactApplicationContext, keyId)
|
|
230
|
+
if (record == null) {
|
|
231
|
+
settler.reject(CODE_KEY_NOT_FOUND, "No secret is stored under this keyId.")
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
val (iv, ciphertext) = record
|
|
235
|
+
|
|
236
|
+
val key = try {
|
|
237
|
+
WalletKeystoreCrypto.loadKey(keyId)
|
|
238
|
+
} catch (e: Exception) {
|
|
239
|
+
settler.reject(classify(e), e.message ?: "Could not load the wrapping key.")
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (key == null) {
|
|
244
|
+
// Ciphertext present but wrapping key gone: unrecoverable, not absent.
|
|
245
|
+
// Removing the device lock deletes auth-bound keys outright rather than
|
|
246
|
+
// throwing KeyPermanentlyInvalidatedException, so it lands here.
|
|
247
|
+
settler.reject(
|
|
248
|
+
CODE_KEY_INVALIDATED,
|
|
249
|
+
"The wrapping key no longer exists; this secret cannot be recovered."
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Cipher.init is where a key pinned to a changed biometric enrollment
|
|
255
|
+
// throws, so this is where KEY_INVALIDATED is detected.
|
|
256
|
+
val cipher = try {
|
|
257
|
+
WalletKeystoreCrypto.decryptCipher(key, iv)
|
|
258
|
+
} catch (e: Exception) {
|
|
259
|
+
settler.reject(classify(e), e.message ?: "The wrapping key is no longer usable.")
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Asked of the key rather than discovered by attempting the operation: a
|
|
264
|
+
// failed doFinal leaves the Cipher unusable, so it could not then be handed
|
|
265
|
+
// to the CryptoObject below.
|
|
266
|
+
if (!WalletKeystoreCrypto.requiresAuth(key)) {
|
|
267
|
+
try {
|
|
268
|
+
val plaintext = cipher.doFinal(ciphertext)
|
|
269
|
+
settler.resolve(WalletKeystoreCrypto.hex(plaintext))
|
|
270
|
+
plaintext.fill(0)
|
|
271
|
+
} catch (e: Exception) {
|
|
272
|
+
settler.reject(classify(e), e.message ?: "Could not decrypt the secret.")
|
|
273
|
+
}
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The CryptoObject is what makes this a real boundary rather than a check:
|
|
278
|
+
// the Cipher stays unusable until the OS validates the user, so a
|
|
279
|
+
// compromised JS bundle cannot skip it by faking a boolean.
|
|
280
|
+
withPrompt(
|
|
281
|
+
settler,
|
|
282
|
+
reason = reason,
|
|
283
|
+
policy = POLICY_BIOMETRIC_OR_PASSCODE,
|
|
284
|
+
cipher = cipher,
|
|
285
|
+
onAuthenticated = { authenticated ->
|
|
286
|
+
try {
|
|
287
|
+
val plaintext = authenticated.doFinal(ciphertext)
|
|
288
|
+
settler.resolve(WalletKeystoreCrypto.hex(plaintext))
|
|
289
|
+
plaintext.fill(0)
|
|
290
|
+
} catch (e: Exception) {
|
|
291
|
+
settler.reject(classify(e), e.message ?: "Could not decrypt the secret.")
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
override fun hasSecret(keyId: String, promise: Promise) {
|
|
298
|
+
promise.resolve(WalletKeystoreCrypto.hasRecord(reactApplicationContext, keyId))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
override fun deleteSecret(keyId: String, promise: Promise) {
|
|
302
|
+
// Idempotent: a missing keyId is success for a teardown path.
|
|
303
|
+
WalletKeystoreCrypto.deleteRecord(reactApplicationContext, keyId)
|
|
304
|
+
WalletKeystoreCrypto.deletePublicKey(reactApplicationContext, keyId)
|
|
305
|
+
WalletKeystoreCrypto.deleteKey(keyId)
|
|
306
|
+
promise.resolve(null)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// secp256k1
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
override fun generateKey(
|
|
314
|
+
keyId: String,
|
|
315
|
+
policy: String,
|
|
316
|
+
invalidation: String,
|
|
317
|
+
promise: Promise
|
|
318
|
+
) {
|
|
319
|
+
val guard = PromiseGuard(promise)
|
|
320
|
+
|
|
321
|
+
// Entropy from SecureRandom, never from JS. The private key goes straight
|
|
322
|
+
// into the wrapping path and never crosses the bridge.
|
|
323
|
+
val privateKey = try {
|
|
324
|
+
Secp256k1.generatePrivateKey()
|
|
325
|
+
} catch (e: Exception) {
|
|
326
|
+
guard.reject(CODE_STORAGE_ERROR, e.message ?: "Could not generate a key.")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
wrapPrivateKey(keyId, privateKey, policy, invalidation, guard)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
override fun importPrivateKey(
|
|
334
|
+
keyId: String,
|
|
335
|
+
privateKeyHex: String,
|
|
336
|
+
policy: String,
|
|
337
|
+
invalidation: String,
|
|
338
|
+
promise: Promise
|
|
339
|
+
) {
|
|
340
|
+
val guard = PromiseGuard(promise)
|
|
341
|
+
|
|
342
|
+
val privateKey = WalletKeystoreCrypto.fromHex(privateKeyHex)
|
|
343
|
+
if (privateKey == null || privateKey.size != 32) {
|
|
344
|
+
guard.reject(CODE_INVALID_KEY, "A private key must be exactly 32 bytes of hex.")
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Zero and anything at or above the curve order are not merely malformed:
|
|
349
|
+
// they produce signatures that verify against nothing. Rejected, not clamped.
|
|
350
|
+
if (!Secp256k1.isValidPrivateKey(BigInteger(1, privateKey))) {
|
|
351
|
+
privateKey.fill(0)
|
|
352
|
+
guard.reject(CODE_INVALID_KEY, "The private key must be in [1, n-1].")
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
wrapPrivateKey(keyId, privateKey, policy, invalidation, guard)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Derives the public key, then stores the private key via the v0.2 path. */
|
|
360
|
+
private fun wrapPrivateKey(
|
|
361
|
+
keyId: String,
|
|
362
|
+
privateKey: ByteArray,
|
|
363
|
+
policy: String,
|
|
364
|
+
invalidation: String,
|
|
365
|
+
guard: Settler
|
|
366
|
+
) {
|
|
367
|
+
val publicKeyHex = try {
|
|
368
|
+
WalletKeystoreCrypto.hex(Secp256k1.publicKeyFrom(privateKey))
|
|
369
|
+
} catch (e: Exception) {
|
|
370
|
+
privateKey.fill(0)
|
|
371
|
+
guard.reject(CODE_INVALID_KEY, e.message ?: "Could not derive the public key.")
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
val hex = WalletKeystoreCrypto.hex(privateKey)
|
|
376
|
+
privateKey.fill(0)
|
|
377
|
+
|
|
378
|
+
storeSecretInto(
|
|
379
|
+
keyId, hex, policy, invalidation,
|
|
380
|
+
Relay(
|
|
381
|
+
onResolve = {
|
|
382
|
+
// Recorded only after the wrapping succeeded, so a stored public key
|
|
383
|
+
// always implies a retrievable private one.
|
|
384
|
+
WalletKeystoreCrypto.writePublicKey(reactApplicationContext, keyId, publicKeyHex)
|
|
385
|
+
guard.resolve(publicKeyHex)
|
|
386
|
+
},
|
|
387
|
+
onReject = { code, message -> guard.reject(code, message) }
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
override fun getPublicKey(keyId: String, promise: Promise) {
|
|
393
|
+
val publicKey = WalletKeystoreCrypto.readPublicKey(reactApplicationContext, keyId)
|
|
394
|
+
if (publicKey == null) {
|
|
395
|
+
promise.reject(CODE_KEY_NOT_FOUND, "No key is stored under this keyId.")
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
promise.resolve(publicKey)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
override fun signDigest(
|
|
402
|
+
keyId: String,
|
|
403
|
+
digestHex: String,
|
|
404
|
+
reason: String,
|
|
405
|
+
promise: Promise
|
|
406
|
+
) {
|
|
407
|
+
val guard = PromiseGuard(promise)
|
|
408
|
+
|
|
409
|
+
val digest = WalletKeystoreCrypto.fromHex(digestHex)
|
|
410
|
+
if (digest == null || digest.size != 32) {
|
|
411
|
+
guard.reject(CODE_INVALID_KEY, "A digest must be exactly 32 bytes of hex.")
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
withUnwrappedKey(keyId, reason, guard) { privateKey ->
|
|
416
|
+
try {
|
|
417
|
+
guard.resolve(WalletKeystoreCrypto.hex(Secp256k1.sign(digest, privateKey)))
|
|
418
|
+
} catch (e: Exception) {
|
|
419
|
+
guard.reject(CODE_STORAGE_ERROR, e.message ?: "Could not sign the digest.")
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
override fun exportPrivateKey(keyId: String, reason: String, promise: Promise) {
|
|
425
|
+
val guard = PromiseGuard(promise)
|
|
426
|
+
withUnwrappedKey(keyId, reason, guard) { privateKey ->
|
|
427
|
+
guard.resolve(WalletKeystoreCrypto.hex(privateKey))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Authenticates, decrypts, hands over the raw key, then zeroes it.
|
|
433
|
+
*
|
|
434
|
+
* The zeroing is best-effort by nature — see the README. It shrinks the window
|
|
435
|
+
* in which the key sits in memory; in a managed runtime it cannot close it.
|
|
436
|
+
*/
|
|
437
|
+
private fun withUnwrappedKey(
|
|
438
|
+
keyId: String,
|
|
439
|
+
reason: String,
|
|
440
|
+
guard: Settler,
|
|
441
|
+
use: (ByteArray) -> Unit
|
|
442
|
+
) {
|
|
443
|
+
getSecretInto(
|
|
444
|
+
keyId, reason,
|
|
445
|
+
Relay(
|
|
446
|
+
onResolve = { value ->
|
|
447
|
+
val privateKey = (value as? String)?.let { WalletKeystoreCrypto.fromHex(it) }
|
|
448
|
+
if (privateKey == null) {
|
|
449
|
+
guard.reject(CODE_STORAGE_ERROR, "The stored key is missing or malformed.")
|
|
450
|
+
} else {
|
|
451
|
+
try {
|
|
452
|
+
use(privateKey)
|
|
453
|
+
} finally {
|
|
454
|
+
privateKey.fill(0)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
onReject = { code, message -> guard.reject(code, message) }
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// Prompt plumbing
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
/** Shared BiometricPrompt presentation for the crypto-bound operations. */
|
|
468
|
+
private fun withPrompt(
|
|
469
|
+
settler: Settler,
|
|
470
|
+
reason: String,
|
|
471
|
+
policy: String,
|
|
472
|
+
cipher: Cipher,
|
|
473
|
+
onAuthenticated: (Cipher) -> Unit,
|
|
474
|
+
onSetupFailure: () -> Unit = {}
|
|
475
|
+
) {
|
|
476
|
+
val activity = reactApplicationContext.currentActivity
|
|
477
|
+
if (activity !is FragmentActivity) {
|
|
478
|
+
onSetupFailure()
|
|
479
|
+
settler.reject(
|
|
480
|
+
CODE_NOT_AVAILABLE,
|
|
481
|
+
"A foreground FragmentActivity is required to show the biometric prompt."
|
|
482
|
+
)
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
val authenticators = authenticatorsFor(policy)
|
|
487
|
+
val status = BiometricManager.from(reactApplicationContext).canAuthenticate(authenticators)
|
|
488
|
+
if (status != BiometricManager.BIOMETRIC_SUCCESS) {
|
|
489
|
+
onSetupFailure()
|
|
490
|
+
settler.reject(mapAvailability(status), availabilityMessage(status, authenticators))
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
UiThreadUtil.runOnUiThread {
|
|
495
|
+
try {
|
|
496
|
+
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
|
497
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
498
|
+
// The Cipher handed back by the framework is the authenticated one.
|
|
499
|
+
val authenticated = result.cryptoObject?.cipher
|
|
500
|
+
if (authenticated == null) {
|
|
501
|
+
onSetupFailure()
|
|
502
|
+
settler.reject(CODE_UNKNOWN, "The authenticated cipher was not returned.")
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
onAuthenticated(authenticated)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
509
|
+
onSetupFailure()
|
|
510
|
+
settler.reject(mapAuthError(errorCode), errString.toString())
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// onAuthenticationFailed is intentionally not overridden — see
|
|
514
|
+
// authenticate() above.
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), callback)
|
|
518
|
+
.authenticate(
|
|
519
|
+
buildPromptInfo(reason, authenticators),
|
|
520
|
+
BiometricPrompt.CryptoObject(cipher)
|
|
521
|
+
)
|
|
522
|
+
} catch (e: Exception) {
|
|
523
|
+
onSetupFailure()
|
|
524
|
+
settler.reject(CODE_UNKNOWN, e.message ?: "Failed to present the biometric prompt.")
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private fun buildPromptInfo(reason: String, authenticators: Int): BiometricPrompt.PromptInfo {
|
|
530
|
+
val builder = BiometricPrompt.PromptInfo.Builder()
|
|
531
|
+
.setTitle(reason)
|
|
532
|
+
.setAllowedAuthenticators(authenticators)
|
|
533
|
+
|
|
534
|
+
// The framework supplies its own device-credential affordance, and setting
|
|
535
|
+
// a negative button alongside DEVICE_CREDENTIAL throws. When biometrics are
|
|
536
|
+
// the only authenticator the negative button is mandatory instead — there
|
|
537
|
+
// would otherwise be no way to dismiss the prompt.
|
|
538
|
+
if (authenticators and BiometricManager.Authenticators.DEVICE_CREDENTIAL == 0) {
|
|
539
|
+
builder.setNegativeButtonText("Cancel")
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return builder.build()
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// setAllowedAuthenticators rejects BIOMETRIC_STRONG or DEVICE_CREDENTIAL below
|
|
546
|
+
// API 30, so API 24-29 degrades to biometric-only rather than silently
|
|
547
|
+
// accepting a weaker credential.
|
|
548
|
+
private fun authenticatorsFor(policy: String): Int =
|
|
549
|
+
WalletKeystoreCrypto.authenticatorsFor(policy)
|
|
550
|
+
|
|
551
|
+
private fun mapAvailability(status: Int): String = when (status) {
|
|
552
|
+
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
|
|
553
|
+
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> CODE_NOT_AVAILABLE
|
|
554
|
+
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> CODE_NOT_ENROLLED
|
|
555
|
+
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> CODE_NOT_AVAILABLE
|
|
556
|
+
else -> CODE_UNKNOWN
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private fun availabilityMessage(status: Int, authenticators: Int): String = when (status) {
|
|
560
|
+
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
|
|
561
|
+
"This device has no biometric hardware."
|
|
562
|
+
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
|
|
563
|
+
"Biometric hardware is currently unavailable."
|
|
564
|
+
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
|
|
565
|
+
// A device credential may well be enrolled while this still fails under
|
|
566
|
+
// 'biometricOnly', so the message has to name only what was actually
|
|
567
|
+
// asked for. Saying "or device credential" there sends the user to
|
|
568
|
+
// Settings to re-add a PIN they already have.
|
|
569
|
+
if (authenticators and BiometricManager.Authenticators.DEVICE_CREDENTIAL != 0) {
|
|
570
|
+
"No biometric or device credential is enrolled."
|
|
571
|
+
} else {
|
|
572
|
+
"No biometric is enrolled."
|
|
573
|
+
}
|
|
574
|
+
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED ->
|
|
575
|
+
"A security update is required before biometrics can be used."
|
|
576
|
+
else -> "Biometric authentication is unavailable (status $status)."
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private fun mapAuthError(errorCode: Int): String = when (errorCode) {
|
|
580
|
+
BiometricPrompt.ERROR_HW_NOT_PRESENT,
|
|
581
|
+
BiometricPrompt.ERROR_HW_UNAVAILABLE -> CODE_NOT_AVAILABLE
|
|
582
|
+
|
|
583
|
+
BiometricPrompt.ERROR_NO_BIOMETRICS -> CODE_NOT_ENROLLED
|
|
584
|
+
|
|
585
|
+
// ERROR_NEGATIVE_BUTTON is the Cancel button; ERROR_USER_CANCELED is a
|
|
586
|
+
// dismissal. Both are the user declining, so both stay retryable.
|
|
587
|
+
BiometricPrompt.ERROR_USER_CANCELED,
|
|
588
|
+
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> CODE_USER_CANCELED
|
|
589
|
+
|
|
590
|
+
BiometricPrompt.ERROR_LOCKOUT -> CODE_LOCKOUT
|
|
591
|
+
BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> CODE_LOCKOUT_PERMANENT
|
|
592
|
+
|
|
593
|
+
// ERROR_CANCELED is the system tearing the prompt down; ERROR_TIMEOUT is it
|
|
594
|
+
// expiring untouched. Neither is user intent.
|
|
595
|
+
BiometricPrompt.ERROR_CANCELED,
|
|
596
|
+
BiometricPrompt.ERROR_TIMEOUT -> CODE_SYSTEM_CANCEL
|
|
597
|
+
|
|
598
|
+
else -> CODE_UNKNOWN
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private fun classify(t: Throwable): String = when {
|
|
602
|
+
WalletKeystoreCrypto.isInvalidated(t) -> CODE_KEY_INVALIDATED
|
|
603
|
+
WalletKeystoreCrypto.isNotAuthenticated(t) -> CODE_NOT_ENROLLED
|
|
604
|
+
else -> CODE_STORAGE_ERROR
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
companion object {
|
|
608
|
+
const val NAME = NativeWalletKeystoreSpec.NAME
|
|
609
|
+
|
|
610
|
+
internal const val POLICY_BIOMETRIC_ONLY = "biometricOnly"
|
|
611
|
+
internal const val POLICY_BIOMETRIC_OR_PASSCODE = "biometricOrPasscode"
|
|
612
|
+
internal const val INVALIDATION_ON_ENROLLMENT_CHANGE = "onEnrollmentChange"
|
|
613
|
+
|
|
614
|
+
private const val CODE_NOT_AVAILABLE = "NOT_AVAILABLE"
|
|
615
|
+
private const val CODE_NOT_ENROLLED = "NOT_ENROLLED"
|
|
616
|
+
private const val CODE_USER_CANCELED = "USER_CANCELED"
|
|
617
|
+
private const val CODE_LOCKOUT = "LOCKOUT"
|
|
618
|
+
private const val CODE_LOCKOUT_PERMANENT = "LOCKOUT_PERMANENT"
|
|
619
|
+
private const val CODE_SYSTEM_CANCEL = "SYSTEM_CANCEL"
|
|
620
|
+
private const val CODE_UNKNOWN = "UNKNOWN"
|
|
621
|
+
private const val CODE_KEY_NOT_FOUND = "KEY_NOT_FOUND"
|
|
622
|
+
private const val CODE_KEY_ALREADY_EXISTS = "KEY_ALREADY_EXISTS"
|
|
623
|
+
private const val CODE_KEY_INVALIDATED = "KEY_INVALIDATED"
|
|
624
|
+
private const val CODE_STORAGE_ERROR = "STORAGE_ERROR"
|
|
625
|
+
private const val CODE_INVALID_KEY = "INVALID_KEY"
|
|
626
|
+
}
|
|
627
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.walletkeystore
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import java.util.HashMap
|
|
9
|
+
|
|
10
|
+
class WalletKeystorePackage : BaseReactPackage() {
|
|
11
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
12
|
+
return if (name == WalletKeystoreModule.NAME) {
|
|
13
|
+
WalletKeystoreModule(reactContext)
|
|
14
|
+
} else {
|
|
15
|
+
null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
20
|
+
mapOf(
|
|
21
|
+
WalletKeystoreModule.NAME to ReactModuleInfo(
|
|
22
|
+
name = WalletKeystoreModule.NAME,
|
|
23
|
+
className = WalletKeystoreModule.NAME,
|
|
24
|
+
canOverrideExistingModule = false,
|
|
25
|
+
needsEagerInit = false,
|
|
26
|
+
isCxxModule = false,
|
|
27
|
+
isTurboModule = true
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|