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,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
|
+
}
|