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.
Files changed (39) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +376 -0
  3. package/WalletKeystore.podspec +29 -0
  4. package/android/build.gradle +62 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/walletkeystore/Secp256k1.kt +158 -0
  7. package/android/src/main/java/com/walletkeystore/WalletKeystoreCrypto.kt +233 -0
  8. package/android/src/main/java/com/walletkeystore/WalletKeystoreModule.kt +627 -0
  9. package/android/src/main/java/com/walletkeystore/WalletKeystorePackage.kt +31 -0
  10. package/ios/WalletKeystore.h +5 -0
  11. package/ios/WalletKeystore.mm +895 -0
  12. package/lib/module/NativeWalletKeystore.js +15 -0
  13. package/lib/module/NativeWalletKeystore.js.map +1 -0
  14. package/lib/module/errors.js +57 -0
  15. package/lib/module/errors.js.map +1 -0
  16. package/lib/module/index.js +9 -0
  17. package/lib/module/index.js.map +1 -0
  18. package/lib/module/keystore.js +220 -0
  19. package/lib/module/keystore.js.map +1 -0
  20. package/lib/module/package.json +1 -0
  21. package/lib/module/viem.js +65 -0
  22. package/lib/module/viem.js.map +1 -0
  23. package/lib/typescript/package.json +1 -0
  24. package/lib/typescript/src/NativeWalletKeystore.d.ts +26 -0
  25. package/lib/typescript/src/NativeWalletKeystore.d.ts.map +1 -0
  26. package/lib/typescript/src/errors.d.ts +29 -0
  27. package/lib/typescript/src/errors.d.ts.map +1 -0
  28. package/lib/typescript/src/index.d.ts +3 -0
  29. package/lib/typescript/src/index.d.ts.map +1 -0
  30. package/lib/typescript/src/keystore.d.ts +100 -0
  31. package/lib/typescript/src/keystore.d.ts.map +1 -0
  32. package/lib/typescript/src/viem.d.ts +24 -0
  33. package/lib/typescript/src/viem.d.ts.map +1 -0
  34. package/package.json +210 -0
  35. package/src/NativeWalletKeystore.ts +43 -0
  36. package/src/errors.ts +94 -0
  37. package/src/index.tsx +6 -0
  38. package/src/keystore.ts +317 -0
  39. package/src/viem.ts +99 -0
@@ -0,0 +1,233 @@
1
+ package com.walletkeystore
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import android.security.keystore.KeyGenParameterSpec
6
+ import android.security.keystore.KeyPermanentlyInvalidatedException
7
+ import android.security.keystore.KeyInfo
8
+ import android.security.keystore.KeyProperties
9
+ import android.security.keystore.StrongBoxUnavailableException
10
+ import android.util.Base64
11
+ import androidx.biometric.BiometricManager
12
+ import java.security.KeyStore
13
+ import javax.crypto.Cipher
14
+ import javax.crypto.KeyGenerator
15
+ import javax.crypto.SecretKey
16
+ import javax.crypto.SecretKeyFactory
17
+ import javax.crypto.spec.GCMParameterSpec
18
+
19
+ /**
20
+ * Keystore-backed wrapping keys and the ciphertext store.
21
+ *
22
+ * Separated from the module so the threading and promise handling stay in one
23
+ * file and the crypto in another.
24
+ */
25
+ internal object WalletKeystoreCrypto {
26
+
27
+ const val ANDROID_KEYSTORE = "AndroidKeyStore"
28
+ const val TRANSFORMATION = "AES/GCM/NoPadding"
29
+ const val GCM_TAG_BITS = 128
30
+ private const val PREFS = "com.walletkeystore.secrets"
31
+ private const val KEY_PREFIX = "com.walletkeystore.wrap."
32
+ private const val PUBLIC_KEY_PREFIX = "pub:"
33
+
34
+ fun alias(keyId: String) = KEY_PREFIX + keyId
35
+
36
+ private fun prefs(context: Context) =
37
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
38
+
39
+ // The payload is already encrypted by a hardware-bound key, so
40
+ // EncryptedSharedPreferences would add a second layer over the same threat
41
+ // model for no gain.
42
+ fun readRecord(context: Context, keyId: String): Pair<ByteArray, ByteArray>? {
43
+ val raw = prefs(context).getString(keyId, null) ?: return null
44
+ val parts = raw.split(":")
45
+ if (parts.size != 2) return null
46
+ return try {
47
+ Base64.decode(parts[0], Base64.NO_WRAP) to Base64.decode(parts[1], Base64.NO_WRAP)
48
+ } catch (_: IllegalArgumentException) {
49
+ null
50
+ }
51
+ }
52
+
53
+ fun writeRecord(context: Context, keyId: String, iv: ByteArray, ciphertext: ByteArray) {
54
+ val encoded = Base64.encodeToString(iv, Base64.NO_WRAP) + ":" +
55
+ Base64.encodeToString(ciphertext, Base64.NO_WRAP)
56
+ prefs(context).edit().putString(keyId, encoded).apply()
57
+ }
58
+
59
+ fun deleteRecord(context: Context, keyId: String) {
60
+ prefs(context).edit().remove(keyId).apply()
61
+ }
62
+
63
+ fun hasRecord(context: Context, keyId: String) = prefs(context).contains(keyId)
64
+
65
+ // The public key is stored in the clear, deliberately. Deriving it requires
66
+ // the private key, and nobody should face a biometric prompt to look up their
67
+ // own address.
68
+ fun writePublicKey(context: Context, keyId: String, publicKeyHex: String) {
69
+ prefs(context).edit().putString(PUBLIC_KEY_PREFIX + keyId, publicKeyHex).apply()
70
+ }
71
+
72
+ fun readPublicKey(context: Context, keyId: String): String? =
73
+ prefs(context).getString(PUBLIC_KEY_PREFIX + keyId, null)
74
+
75
+ fun deletePublicKey(context: Context, keyId: String) {
76
+ prefs(context).edit().remove(PUBLIC_KEY_PREFIX + keyId).apply()
77
+ }
78
+
79
+ fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
80
+
81
+ fun hasKey(keyId: String): Boolean =
82
+ runCatching { keyStore().containsAlias(alias(keyId)) }.getOrDefault(false)
83
+
84
+ fun deleteKey(keyId: String) {
85
+ runCatching { keyStore().deleteEntry(alias(keyId)) }
86
+ }
87
+
88
+ /**
89
+ * Result of generating a wrapping key, including whether StrongBox actually
90
+ * backed it — the caller cannot otherwise tell, and "probably hardware" is
91
+ * not a useful thing to tell a wallet user.
92
+ */
93
+ data class GeneratedKey(val key: SecretKey, val strongBoxBacked: Boolean)
94
+
95
+ fun generateKey(
96
+ keyId: String,
97
+ policy: String,
98
+ invalidation: String
99
+ ): GeneratedKey {
100
+ fun build(strongBox: Boolean): SecretKey {
101
+ val builder = KeyGenParameterSpec.Builder(
102
+ alias(keyId),
103
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
104
+ )
105
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
106
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
107
+ .setKeySize(256)
108
+ .setUserAuthenticationRequired(true)
109
+
110
+ // Pinning the key to the current biometric enrollment destroys it when
111
+ // the user adds or removes a fingerprint. That is why invalidation is
112
+ // never implied by the auth policy: opting in silently would lose wallets.
113
+ builder.setInvalidatedByBiometricEnrollment(
114
+ invalidation == WalletKeystoreModule.INVALIDATION_ON_ENROLLMENT_CHANGE
115
+ )
116
+
117
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
118
+ // 0 means "authenticate for every single use", which is what makes the
119
+ // CryptoObject binding meaningful — a time window would let a later
120
+ // operation ride on an earlier authentication.
121
+ builder.setUserAuthenticationParameters(
122
+ 0,
123
+ if (policy == WalletKeystoreModule.POLICY_BIOMETRIC_ONLY) {
124
+ KeyProperties.AUTH_BIOMETRIC_STRONG
125
+ } else {
126
+ KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
127
+ }
128
+ )
129
+ } else {
130
+ @Suppress("DEPRECATION")
131
+ builder.setUserAuthenticationValidityDurationSeconds(-1)
132
+ }
133
+
134
+ if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
135
+ builder.setIsStrongBoxBacked(true)
136
+ }
137
+
138
+ val generator = KeyGenerator.getInstance(
139
+ KeyProperties.KEY_ALGORITHM_AES,
140
+ ANDROID_KEYSTORE
141
+ )
142
+ generator.init(builder.build())
143
+ return generator.generateKey()
144
+ }
145
+
146
+ // StrongBox is absent on most devices and throws rather than degrading, so
147
+ // the fallback is mandatory. Which one was used is reported back rather
148
+ // than hidden.
149
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
150
+ try {
151
+ GeneratedKey(build(strongBox = true), true)
152
+ } catch (_: StrongBoxUnavailableException) {
153
+ deleteKey(keyId)
154
+ GeneratedKey(build(strongBox = false), false)
155
+ }
156
+ } else {
157
+ GeneratedKey(build(strongBox = false), false)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Asked of the key rather than inferred from a policy string, which is not
163
+ * recorded anywhere. Fails closed: unreadable metadata means prompt.
164
+ */
165
+ fun requiresAuth(key: SecretKey): Boolean = try {
166
+ val factory = SecretKeyFactory.getInstance(key.algorithm, ANDROID_KEYSTORE)
167
+ (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo)
168
+ .isUserAuthenticationRequired
169
+ } catch (_: Exception) {
170
+ true
171
+ }
172
+
173
+ /**
174
+ * Keystore wraps "used without authentication" in an IllegalBlockSizeException
175
+ * rather than throwing UserNotAuthenticatedException, so walk the cause chain.
176
+ */
177
+ fun isNotAuthenticated(t: Throwable): Boolean {
178
+ var current: Throwable? = t
179
+ while (current != null) {
180
+ if (current is android.security.keystore.UserNotAuthenticatedException) return true
181
+ if (current::class.java.name.endsWith("KeyStoreException") &&
182
+ current.message?.contains("not authenticated", ignoreCase = true) == true
183
+ ) {
184
+ return true
185
+ }
186
+ current = current.cause
187
+ }
188
+ return false
189
+ }
190
+
191
+ fun loadKey(keyId: String): SecretKey? =
192
+ keyStore().getKey(alias(keyId), null) as? SecretKey
193
+
194
+ fun encryptCipher(key: SecretKey): Cipher =
195
+ Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key) }
196
+
197
+ fun decryptCipher(key: SecretKey, iv: ByteArray): Cipher =
198
+ Cipher.getInstance(TRANSFORMATION).apply {
199
+ init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
200
+ }
201
+
202
+ fun authenticatorsFor(policy: String): Int =
203
+ if (policy == WalletKeystoreModule.POLICY_BIOMETRIC_ONLY) {
204
+ BiometricManager.Authenticators.BIOMETRIC_STRONG
205
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
206
+ BiometricManager.Authenticators.BIOMETRIC_STRONG or
207
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL
208
+ } else {
209
+ BiometricManager.Authenticators.BIOMETRIC_STRONG
210
+ }
211
+
212
+ /**
213
+ * A key pinned to a biometric enrollment that has since changed throws on
214
+ * init, and the secret it wrapped is gone for good. Distinguished from a
215
+ * transient failure so callers can start recovery instead of retrying.
216
+ */
217
+ fun isInvalidated(t: Throwable): Boolean =
218
+ t is KeyPermanentlyInvalidatedException
219
+
220
+ fun hex(bytes: ByteArray): String =
221
+ bytes.joinToString("") { "%02x".format(it) }
222
+
223
+ fun fromHex(hex: String): ByteArray? {
224
+ if (hex.length % 2 != 0 || hex.isEmpty()) return null
225
+ return try {
226
+ ByteArray(hex.length / 2) {
227
+ hex.substring(it * 2, it * 2 + 2).toInt(16).toByte()
228
+ }
229
+ } catch (_: NumberFormatException) {
230
+ null
231
+ }
232
+ }
233
+ }