@qntx/wallet-native 0.0.0-stage → 0.4.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/README.md +123 -2
- package/WalletNative.podspec +74 -0
- package/android/CMakeLists.txt +99 -0
- package/android/build.gradle +177 -0
- package/android/consumer-rules.pro +4 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +29 -0
- package/android/src/main/java/com/margelo/nitro/qntxwallet/AppLifecycle.kt +35 -0
- package/android/src/main/java/com/margelo/nitro/qntxwallet/KernelPbkdf2.java +59 -0
- package/android/src/main/java/com/margelo/nitro/qntxwallet/Keystore.kt +280 -0
- package/android/src/main/java/com/margelo/nitro/qntxwallet/Passkey.kt +147 -0
- package/android/src/main/java/com/margelo/nitro/qntxwallet/VaultDirectory.kt +28 -0
- package/android/src/main/java/com/margelo/nitro/qntxwallet/WalletNativePackage.java +30 -0
- package/cpp/host/HybridKernelHost.cpp +162 -0
- package/cpp/host/HybridKernelHost.hpp +49 -0
- package/cpp/host/KernelFile.cpp +120 -0
- package/cpp/host/KernelFile.hpp +24 -0
- package/cpp/host/Keystore.hpp +70 -0
- package/cpp/host/Lifecycle.cpp +77 -0
- package/cpp/host/Lifecycle.hpp +71 -0
- package/cpp/host/Passkey.hpp +39 -0
- package/cpp/host/VaultDirectory.hpp +16 -0
- package/cpp/platform/KeystoreAndroid.cpp +175 -0
- package/cpp/platform/KeystoreIos.mm +300 -0
- package/cpp/platform/LifecycleAndroid.cpp +55 -0
- package/cpp/platform/LifecycleIos.mm +36 -0
- package/cpp/platform/PasskeyAndroid.cpp +118 -0
- package/cpp/platform/PasskeyIos.mm +20 -0
- package/cpp/platform/VaultDirectory.mm +58 -0
- package/cpp/platform/VaultDirectoryAndroid.cpp +35 -0
- package/cpp/runtime/KernelBackends.cpp +297 -0
- package/cpp/runtime/KernelBackends.hpp +20 -0
- package/cpp/runtime/KernelBuffer.cpp +12 -0
- package/cpp/runtime/KernelBuffer.hpp +96 -0
- package/cpp/runtime/KernelGlobals.cpp +535 -0
- package/cpp/runtime/KernelGlobals.hpp +107 -0
- package/cpp/runtime/KernelJs.cpp +155 -0
- package/cpp/runtime/KernelJs.hpp +81 -0
- package/cpp/runtime/KernelKeystore.cpp +224 -0
- package/cpp/runtime/KernelKeystore.hpp +28 -0
- package/cpp/runtime/KernelPasskey.cpp +137 -0
- package/cpp/runtime/KernelPasskey.hpp +23 -0
- package/cpp/runtime/KernelRuntime.cpp +496 -0
- package/cpp/runtime/KernelRuntime.hpp +94 -0
- package/cpp/runtime/platform/KernelPbkdf2.cpp +198 -0
- package/cpp/runtime/platform/KernelPbkdf2.hpp +38 -0
- package/dist/index.d.mts +44 -0
- package/dist/index.mjs +170 -0
- package/dist/index.mjs.map +1 -0
- package/kernel/generated/kernel-entry.js +42967 -0
- package/kernel/generated/kernel.manifest.json +80 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/WalletNative+autolinking.cmake +81 -0
- package/nitrogen/generated/android/WalletNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/WalletNativeOnLoad.cpp +49 -0
- package/nitrogen/generated/android/WalletNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/qntxwallet/WalletNativeOnLoad.kt +35 -0
- package/nitrogen/generated/ios/WalletNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/WalletNative-Swift-Cxx-Bridge.cpp +17 -0
- package/nitrogen/generated/ios/WalletNative-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/WalletNative-Swift-Cxx-Umbrella.hpp +38 -0
- package/nitrogen/generated/ios/WalletNativeAutolinking.mm +35 -0
- package/nitrogen/generated/ios/WalletNativeAutolinking.swift +16 -0
- package/nitrogen/generated/shared/c++/HybridKernelHostSpec.cpp +25 -0
- package/nitrogen/generated/shared/c++/HybridKernelHostSpec.hpp +69 -0
- package/nitrogen/generated/shared/c++/KernelExitReason.hpp +84 -0
- package/package.json +61 -4
- package/react-native.config.js +14 -0
- package/scripts/compile-kernel.mjs +118 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
package com.margelo.nitro.qntxwallet
|
|
2
|
+
|
|
3
|
+
import android.app.KeyguardManager
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.os.LocaleList
|
|
7
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
8
|
+
import android.security.keystore.KeyPermanentlyInvalidatedException
|
|
9
|
+
import android.security.keystore.KeyProperties
|
|
10
|
+
import android.security.keystore.StrongBoxUnavailableException
|
|
11
|
+
import androidx.annotation.Keep
|
|
12
|
+
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
|
13
|
+
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
|
14
|
+
import androidx.biometric.BiometricPrompt
|
|
15
|
+
import androidx.core.content.ContextCompat
|
|
16
|
+
import androidx.fragment.app.FragmentActivity
|
|
17
|
+
import androidx.lifecycle.DefaultLifecycleObserver
|
|
18
|
+
import androidx.lifecycle.Lifecycle
|
|
19
|
+
import androidx.lifecycle.LifecycleOwner
|
|
20
|
+
import com.facebook.proguard.annotations.DoNotStrip
|
|
21
|
+
import com.margelo.nitro.NitroModules
|
|
22
|
+
import java.security.KeyStore
|
|
23
|
+
import java.util.concurrent.Executor
|
|
24
|
+
import java.security.UnrecoverableKeyException
|
|
25
|
+
import javax.crypto.Cipher
|
|
26
|
+
import javax.crypto.KeyGenerator
|
|
27
|
+
import javax.crypto.SecretKey
|
|
28
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Android keystore (W5): one AndroidKeyStore AES-256-GCM key per vault factor
|
|
32
|
+
* (`qntx-wallet.vault.<factor id>`), StrongBox-backed when present. Every use
|
|
33
|
+
* needs a fresh BiometricPrompt (strong biometric or device credential) bound
|
|
34
|
+
* to the cipher through a CryptoObject; the key survives biometric enrollment
|
|
35
|
+
* changes and dies with the lock screen. The C++ host calls these entry points
|
|
36
|
+
* with a completion token and receives the result through [nativeComplete].
|
|
37
|
+
* Sealed bytes are `iv(12) ‖ ciphertext ‖ tag(16)`.
|
|
38
|
+
*/
|
|
39
|
+
@DoNotStrip
|
|
40
|
+
@Keep
|
|
41
|
+
object Keystore {
|
|
42
|
+
private const val PREFIX = "qntx-wallet.vault."
|
|
43
|
+
private const val PROVIDER = "AndroidKeyStore"
|
|
44
|
+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
45
|
+
private const val IV_BYTES = 12
|
|
46
|
+
private const val TAG_BITS = 128
|
|
47
|
+
|
|
48
|
+
// Status codes shared with KeystoreAndroid.cpp.
|
|
49
|
+
private const val OK = 0
|
|
50
|
+
private const val CANCELLED = 1
|
|
51
|
+
private const val INVALIDATED = 2
|
|
52
|
+
private const val UNAVAILABLE = 3
|
|
53
|
+
private const val FAILED = 4
|
|
54
|
+
|
|
55
|
+
@Volatile private var current: BiometricPrompt? = null
|
|
56
|
+
|
|
57
|
+
@JvmStatic
|
|
58
|
+
fun locale(): String = LocaleList.getDefault()[0]?.toLanguageTag() ?: "en"
|
|
59
|
+
|
|
60
|
+
@JvmStatic
|
|
61
|
+
fun seal(token: Long, id: String, secret: ByteArray, title: String, description: String) {
|
|
62
|
+
val activity = foregroundActivity()
|
|
63
|
+
if (activity == null || !deviceSecure(activity)) {
|
|
64
|
+
secret.fill(0)
|
|
65
|
+
return nativeComplete(token, UNAVAILABLE, null)
|
|
66
|
+
}
|
|
67
|
+
val alias = PREFIX + id
|
|
68
|
+
val cipher = try {
|
|
69
|
+
keyStore().deleteEntry(alias)
|
|
70
|
+
Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, generate(alias, activity)) }
|
|
71
|
+
} catch (e: Exception) {
|
|
72
|
+
secret.fill(0)
|
|
73
|
+
return nativeComplete(token, UNAVAILABLE, null)
|
|
74
|
+
}
|
|
75
|
+
prompt(activity, title, description, BiometricPrompt.CryptoObject(cipher)) { status, crypto ->
|
|
76
|
+
try {
|
|
77
|
+
val authenticated = crypto?.cipher
|
|
78
|
+
if (status != OK || authenticated == null) {
|
|
79
|
+
keyStore().deleteEntry(alias)
|
|
80
|
+
nativeComplete(token, if (status == OK) FAILED else status, null)
|
|
81
|
+
} else {
|
|
82
|
+
nativeComplete(token, OK, authenticated.iv + authenticated.doFinal(secret))
|
|
83
|
+
}
|
|
84
|
+
} catch (e: Exception) {
|
|
85
|
+
keyStore().deleteEntry(alias)
|
|
86
|
+
nativeComplete(token, FAILED, null)
|
|
87
|
+
} finally {
|
|
88
|
+
secret.fill(0)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@JvmStatic
|
|
94
|
+
fun open(token: Long, id: String, sealed: ByteArray, title: String, description: String) {
|
|
95
|
+
val activity = foregroundActivity() ?: return nativeComplete(token, UNAVAILABLE, null)
|
|
96
|
+
if (sealed.size <= IV_BYTES + TAG_BITS / 8) {
|
|
97
|
+
return nativeComplete(token, FAILED, null)
|
|
98
|
+
}
|
|
99
|
+
val alias = PREFIX + id
|
|
100
|
+
val key = try {
|
|
101
|
+
keyStore().getKey(alias, null) as? SecretKey
|
|
102
|
+
} catch (e: UnrecoverableKeyException) {
|
|
103
|
+
null
|
|
104
|
+
} ?: return nativeComplete(token, INVALIDATED, null)
|
|
105
|
+
val cipher = try {
|
|
106
|
+
Cipher.getInstance(TRANSFORMATION).apply {
|
|
107
|
+
init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, sealed, 0, IV_BYTES))
|
|
108
|
+
}
|
|
109
|
+
} catch (e: KeyPermanentlyInvalidatedException) {
|
|
110
|
+
keyStore().deleteEntry(alias)
|
|
111
|
+
return nativeComplete(token, INVALIDATED, null)
|
|
112
|
+
} catch (e: Exception) {
|
|
113
|
+
return nativeComplete(token, FAILED, null)
|
|
114
|
+
}
|
|
115
|
+
prompt(activity, title, description, BiometricPrompt.CryptoObject(cipher)) { status, crypto ->
|
|
116
|
+
val authenticated = crypto?.cipher
|
|
117
|
+
if (status != OK || authenticated == null) {
|
|
118
|
+
nativeComplete(token, if (status == OK) FAILED else status, null)
|
|
119
|
+
return@prompt
|
|
120
|
+
}
|
|
121
|
+
val plain = try {
|
|
122
|
+
authenticated.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES)
|
|
123
|
+
} catch (e: Exception) {
|
|
124
|
+
return@prompt nativeComplete(token, FAILED, null)
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
nativeComplete(token, OK, plain)
|
|
128
|
+
} finally {
|
|
129
|
+
plain.fill(0)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@JvmStatic
|
|
135
|
+
fun authenticate(token: Long, title: String, description: String) {
|
|
136
|
+
val activity = foregroundActivity()
|
|
137
|
+
if (activity == null || !deviceSecure(activity)) {
|
|
138
|
+
return nativeComplete(token, UNAVAILABLE, null)
|
|
139
|
+
}
|
|
140
|
+
prompt(activity, title, description, null) { status, _ -> nativeComplete(token, status, null) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@JvmStatic
|
|
144
|
+
fun clear(token: Long) {
|
|
145
|
+
val status = try {
|
|
146
|
+
val store = keyStore()
|
|
147
|
+
store.aliases().toList().filter { it.startsWith(PREFIX) }.forEach(store::deleteEntry)
|
|
148
|
+
OK
|
|
149
|
+
} catch (e: Exception) {
|
|
150
|
+
FAILED
|
|
151
|
+
}
|
|
152
|
+
nativeComplete(token, status, null)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@JvmStatic
|
|
156
|
+
fun cancel() {
|
|
157
|
+
val activity = foregroundActivity() ?: return
|
|
158
|
+
ContextCompat.getMainExecutor(activity).execute { current?.cancelAuthentication() }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private fun foregroundActivity(): FragmentActivity? =
|
|
162
|
+
NitroModules.applicationContext?.currentActivity as? FragmentActivity
|
|
163
|
+
|
|
164
|
+
private fun deviceSecure(context: Context): Boolean =
|
|
165
|
+
context.getSystemService(KeyguardManager::class.java)?.isDeviceSecure == true
|
|
166
|
+
|
|
167
|
+
private fun keyStore(): KeyStore = KeyStore.getInstance(PROVIDER).apply { load(null) }
|
|
168
|
+
|
|
169
|
+
private fun generate(alias: String, context: Context): SecretKey {
|
|
170
|
+
val strongBox = context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
|
171
|
+
return try {
|
|
172
|
+
generate(alias, strongBox)
|
|
173
|
+
} catch (e: StrongBoxUnavailableException) {
|
|
174
|
+
generate(alias, false)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private fun generate(alias: String, strongBox: Boolean): SecretKey {
|
|
179
|
+
val spec = KeyGenParameterSpec.Builder(
|
|
180
|
+
alias,
|
|
181
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
182
|
+
)
|
|
183
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
184
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
185
|
+
.setKeySize(256)
|
|
186
|
+
.setUserAuthenticationRequired(true)
|
|
187
|
+
.setUserAuthenticationParameters(
|
|
188
|
+
0,
|
|
189
|
+
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL,
|
|
190
|
+
)
|
|
191
|
+
.setUnlockedDeviceRequired(true)
|
|
192
|
+
.setInvalidatedByBiometricEnrollment(false)
|
|
193
|
+
.setIsStrongBoxBacked(strongBox)
|
|
194
|
+
.build()
|
|
195
|
+
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER)
|
|
196
|
+
.apply { init(spec) }
|
|
197
|
+
.generateKey()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Shows one system verification on the main thread; [done] runs there with the outcome. */
|
|
201
|
+
private fun prompt(
|
|
202
|
+
activity: FragmentActivity,
|
|
203
|
+
title: String,
|
|
204
|
+
description: String,
|
|
205
|
+
crypto: BiometricPrompt.CryptoObject?,
|
|
206
|
+
done: (Int, BiometricPrompt.CryptoObject?) -> Unit,
|
|
207
|
+
) {
|
|
208
|
+
val executor = ContextCompat.getMainExecutor(activity)
|
|
209
|
+
executor.execute { whenResumed(activity) { show(activity, executor, title, description, crypto, done) } }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A prompt started while the activity is not resumed — e.g. right after a
|
|
213
|
+
// Credential Manager sheet finishes and before our activity is back on top —
|
|
214
|
+
// is cancelled by the framework at once. Start it on the next ON_RESUME.
|
|
215
|
+
private fun whenResumed(activity: FragmentActivity, action: () -> Unit) {
|
|
216
|
+
val lifecycle = activity.lifecycle
|
|
217
|
+
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
|
218
|
+
action()
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
lifecycle.addObserver(object : DefaultLifecycleObserver {
|
|
222
|
+
override fun onResume(owner: LifecycleOwner) {
|
|
223
|
+
lifecycle.removeObserver(this)
|
|
224
|
+
action()
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private fun show(
|
|
230
|
+
activity: FragmentActivity,
|
|
231
|
+
executor: Executor,
|
|
232
|
+
title: String,
|
|
233
|
+
description: String,
|
|
234
|
+
crypto: BiometricPrompt.CryptoObject?,
|
|
235
|
+
done: (Int, BiometricPrompt.CryptoObject?) -> Unit,
|
|
236
|
+
) {
|
|
237
|
+
val info = BiometricPrompt.PromptInfo.Builder()
|
|
238
|
+
.setTitle(title)
|
|
239
|
+
.setDescription(description)
|
|
240
|
+
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
|
241
|
+
.build()
|
|
242
|
+
val biometricPrompt = BiometricPrompt(
|
|
243
|
+
activity,
|
|
244
|
+
executor,
|
|
245
|
+
object : BiometricPrompt.AuthenticationCallback() {
|
|
246
|
+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
247
|
+
current = null
|
|
248
|
+
done(OK, result.cryptoObject)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
252
|
+
current = null
|
|
253
|
+
done(statusOf(errorCode), null)
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
current = biometricPrompt
|
|
258
|
+
try {
|
|
259
|
+
if (crypto == null) biometricPrompt.authenticate(info) else biometricPrompt.authenticate(info, crypto)
|
|
260
|
+
} catch (e: Exception) {
|
|
261
|
+
current = null
|
|
262
|
+
done(FAILED, null)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private fun statusOf(errorCode: Int): Int = when (errorCode) {
|
|
267
|
+
BiometricPrompt.ERROR_USER_CANCELED,
|
|
268
|
+
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
|
|
269
|
+
BiometricPrompt.ERROR_CANCELED,
|
|
270
|
+
BiometricPrompt.ERROR_TIMEOUT -> CANCELLED
|
|
271
|
+
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL,
|
|
272
|
+
BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
|
273
|
+
BiometricPrompt.ERROR_HW_NOT_PRESENT,
|
|
274
|
+
BiometricPrompt.ERROR_NO_BIOMETRICS -> UNAVAILABLE
|
|
275
|
+
else -> FAILED
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@JvmStatic
|
|
279
|
+
private external fun nativeComplete(token: Long, status: Int, bytes: ByteArray?)
|
|
280
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
package com.margelo.nitro.qntxwallet
|
|
2
|
+
|
|
3
|
+
import android.os.CancellationSignal
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import androidx.annotation.Keep
|
|
6
|
+
import androidx.core.content.ContextCompat
|
|
7
|
+
import androidx.credentials.CreateCredentialResponse
|
|
8
|
+
import androidx.credentials.CreatePublicKeyCredentialRequest
|
|
9
|
+
import androidx.credentials.CreatePublicKeyCredentialResponse
|
|
10
|
+
import androidx.credentials.CredentialManager
|
|
11
|
+
import androidx.credentials.CredentialManagerCallback
|
|
12
|
+
import androidx.credentials.GetCredentialRequest
|
|
13
|
+
import androidx.credentials.GetCredentialResponse
|
|
14
|
+
import androidx.credentials.GetPublicKeyCredentialOption
|
|
15
|
+
import androidx.credentials.PublicKeyCredential
|
|
16
|
+
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
|
17
|
+
import androidx.credentials.exceptions.CreateCredentialException
|
|
18
|
+
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
|
|
19
|
+
import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException
|
|
20
|
+
import androidx.credentials.exceptions.CreateCredentialUnsupportedException
|
|
21
|
+
import androidx.credentials.exceptions.GetCredentialCancellationException
|
|
22
|
+
import androidx.credentials.exceptions.GetCredentialException
|
|
23
|
+
import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
|
|
24
|
+
import androidx.credentials.exceptions.GetCredentialUnsupportedException
|
|
25
|
+
import androidx.credentials.exceptions.NoCredentialException
|
|
26
|
+
import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
|
|
27
|
+
import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException
|
|
28
|
+
import androidx.fragment.app.FragmentActivity
|
|
29
|
+
import com.facebook.proguard.annotations.DoNotStrip
|
|
30
|
+
import com.margelo.nitro.NitroModules
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Android passkey authenticator (W6): Credential Manager with the kernel's
|
|
34
|
+
* WebAuthn JSON. Requests and responses stay JSON — the kernel builds the
|
|
35
|
+
* options (PRF eval included) and parses the response itself, so this object
|
|
36
|
+
* only runs the system ceremony. The C++ host calls [create] and [get] with a
|
|
37
|
+
* completion token and receives the outcome through [nativeComplete].
|
|
38
|
+
*/
|
|
39
|
+
@DoNotStrip
|
|
40
|
+
@Keep
|
|
41
|
+
object Passkey {
|
|
42
|
+
// Status codes shared with PasskeyAndroid.cpp.
|
|
43
|
+
private const val OK = 0
|
|
44
|
+
private const val CANCELLED = 1
|
|
45
|
+
private const val UNAVAILABLE = 3
|
|
46
|
+
private const val FAILED = 4
|
|
47
|
+
// The exception type only — never the message, which can name the account.
|
|
48
|
+
private const val TAG = "QntxPasskey"
|
|
49
|
+
|
|
50
|
+
@Volatile private var signal: CancellationSignal? = null
|
|
51
|
+
|
|
52
|
+
@JvmStatic
|
|
53
|
+
fun create(token: Long, requestJson: String) {
|
|
54
|
+
val activity = foregroundActivity() ?: return nativeComplete(token, UNAVAILABLE, null)
|
|
55
|
+
val cancel = CancellationSignal().also { signal = it }
|
|
56
|
+
CredentialManager.create(activity).createCredentialAsync(
|
|
57
|
+
activity,
|
|
58
|
+
CreatePublicKeyCredentialRequest(requestJson),
|
|
59
|
+
cancel,
|
|
60
|
+
ContextCompat.getMainExecutor(activity),
|
|
61
|
+
object : CredentialManagerCallback<CreateCredentialResponse, CreateCredentialException> {
|
|
62
|
+
override fun onResult(result: CreateCredentialResponse) {
|
|
63
|
+
signal = null
|
|
64
|
+
val response = result as? CreatePublicKeyCredentialResponse
|
|
65
|
+
nativeComplete(
|
|
66
|
+
token,
|
|
67
|
+
if (response == null) FAILED else OK,
|
|
68
|
+
response?.registrationResponseJson,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun onError(e: CreateCredentialException) {
|
|
73
|
+
signal = null
|
|
74
|
+
Log.w(TAG, "create failed: ${e.type}")
|
|
75
|
+
nativeComplete(token, statusOf(e), null)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@JvmStatic
|
|
82
|
+
fun get(token: Long, requestJson: String) {
|
|
83
|
+
val activity = foregroundActivity() ?: return nativeComplete(token, UNAVAILABLE, null)
|
|
84
|
+
val cancel = CancellationSignal().also { signal = it }
|
|
85
|
+
CredentialManager.create(activity).getCredentialAsync(
|
|
86
|
+
activity,
|
|
87
|
+
GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson))),
|
|
88
|
+
cancel,
|
|
89
|
+
ContextCompat.getMainExecutor(activity),
|
|
90
|
+
object : CredentialManagerCallback<GetCredentialResponse, GetCredentialException> {
|
|
91
|
+
override fun onResult(result: GetCredentialResponse) {
|
|
92
|
+
signal = null
|
|
93
|
+
val credential = result.credential as? PublicKeyCredential
|
|
94
|
+
nativeComplete(
|
|
95
|
+
token,
|
|
96
|
+
if (credential == null) FAILED else OK,
|
|
97
|
+
credential?.authenticationResponseJson,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override fun onError(e: GetCredentialException) {
|
|
102
|
+
signal = null
|
|
103
|
+
Log.w(TAG, "get failed: ${e.type}")
|
|
104
|
+
nativeComplete(token, statusOf(e), null)
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@JvmStatic
|
|
111
|
+
fun cancel() {
|
|
112
|
+
signal?.cancel()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private fun foregroundActivity(): FragmentActivity? =
|
|
116
|
+
NitroModules.applicationContext?.currentActivity as? FragmentActivity
|
|
117
|
+
|
|
118
|
+
// NotAllowedError is what the platform reports when the user dismisses or
|
|
119
|
+
// times out the sheet; SecurityError means the RP id is not associated
|
|
120
|
+
// with this app (assetlinks), i.e. passkeys are unavailable here.
|
|
121
|
+
private fun statusOf(e: CreateCredentialException): Int = when (e) {
|
|
122
|
+
is CreateCredentialCancellationException -> CANCELLED
|
|
123
|
+
is CreateCredentialNoCreateOptionException,
|
|
124
|
+
is CreateCredentialProviderConfigurationException,
|
|
125
|
+
is CreateCredentialUnsupportedException -> UNAVAILABLE
|
|
126
|
+
is CreatePublicKeyCredentialDomException -> domStatus(e.type)
|
|
127
|
+
else -> FAILED
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private fun statusOf(e: GetCredentialException): Int = when (e) {
|
|
131
|
+
is GetCredentialCancellationException -> CANCELLED
|
|
132
|
+
is NoCredentialException,
|
|
133
|
+
is GetCredentialProviderConfigurationException,
|
|
134
|
+
is GetCredentialUnsupportedException -> UNAVAILABLE
|
|
135
|
+
is GetPublicKeyCredentialDomException -> domStatus(e.type)
|
|
136
|
+
else -> FAILED
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private fun domStatus(type: String): Int = when {
|
|
140
|
+
type.endsWith("NotAllowedError") -> CANCELLED
|
|
141
|
+
type.endsWith("SecurityError") || type.endsWith("NotSupportedError") -> UNAVAILABLE
|
|
142
|
+
else -> FAILED
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@JvmStatic
|
|
146
|
+
private external fun nativeComplete(token: Long, status: Int, responseJson: String?)
|
|
147
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package com.margelo.nitro.qntxwallet
|
|
2
|
+
|
|
3
|
+
import androidx.annotation.Keep
|
|
4
|
+
import com.facebook.proguard.annotations.DoNotStrip
|
|
5
|
+
import com.margelo.nitro.NitroModules
|
|
6
|
+
import java.io.File
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Vault directory hook: `<noBackupFilesDir>/qntx-wallet`, pulled from C++
|
|
10
|
+
* through fbjni — `noBackupFilesDir` needs a Context, which Nitro hands us once
|
|
11
|
+
* its TurboModule is constructed (before any `createHybridObject("KernelHost")`).
|
|
12
|
+
*/
|
|
13
|
+
object VaultDirectory {
|
|
14
|
+
@JvmStatic
|
|
15
|
+
@DoNotStrip
|
|
16
|
+
@Keep
|
|
17
|
+
fun resolve(): String {
|
|
18
|
+
val context = NitroModules.applicationContext
|
|
19
|
+
?: throw IllegalStateException(
|
|
20
|
+
"vault directory: NitroModules.applicationContext is not set")
|
|
21
|
+
val dir = File(context.noBackupFilesDir, "qntx-wallet")
|
|
22
|
+
dir.mkdirs()
|
|
23
|
+
if (!dir.isDirectory) {
|
|
24
|
+
throw IllegalStateException("vault directory: mkdirs failed")
|
|
25
|
+
}
|
|
26
|
+
return dir.absolutePath
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package com.margelo.nitro.qntxwallet;
|
|
2
|
+
|
|
3
|
+
import androidx.annotation.Nullable;
|
|
4
|
+
|
|
5
|
+
import com.facebook.react.bridge.NativeModule;
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider;
|
|
8
|
+
import com.facebook.react.BaseReactPackage;
|
|
9
|
+
|
|
10
|
+
import java.util.HashMap;
|
|
11
|
+
|
|
12
|
+
public class WalletNativePackage extends BaseReactPackage {
|
|
13
|
+
@Nullable
|
|
14
|
+
@Override
|
|
15
|
+
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Override
|
|
20
|
+
public ReactModuleInfoProvider getReactModuleInfoProvider() {
|
|
21
|
+
return () -> {
|
|
22
|
+
return new HashMap<>();
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static {
|
|
27
|
+
WalletNativeOnLoad.initializeNative();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#include "HybridKernelHost.hpp"
|
|
2
|
+
|
|
3
|
+
#include "kernel.hpp"
|
|
4
|
+
#include "VaultDirectory.hpp"
|
|
5
|
+
|
|
6
|
+
#if defined(__ANDROID__)
|
|
7
|
+
#include <android/log.h>
|
|
8
|
+
#elif defined(__APPLE__)
|
|
9
|
+
#include <os/log.h>
|
|
10
|
+
#endif
|
|
11
|
+
|
|
12
|
+
namespace margelo::nitro::qntxwallet {
|
|
13
|
+
|
|
14
|
+
namespace {
|
|
15
|
+
|
|
16
|
+
// A kernel crash reaches JS only as "crashed"; the reason goes to the system
|
|
17
|
+
// log so field reports and device runs can tell causes apart. Kernel error
|
|
18
|
+
// messages are static strings that never echo input or secrets.
|
|
19
|
+
void logKernelCrash(const std::string& reason) {
|
|
20
|
+
#if defined(__ANDROID__)
|
|
21
|
+
__android_log_print(ANDROID_LOG_ERROR, "QntxKernel", "kernel runtime crashed: %s", reason.c_str());
|
|
22
|
+
#elif defined(__APPLE__)
|
|
23
|
+
os_log_error(OS_LOG_DEFAULT, "qntx kernel runtime crashed: %{public}s", reason.c_str());
|
|
24
|
+
#else
|
|
25
|
+
(void)reason;
|
|
26
|
+
#endif
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
} // namespace
|
|
30
|
+
|
|
31
|
+
HybridKernelHost::~HybridKernelHost() {
|
|
32
|
+
std::shared_ptr<qntx::KernelRuntime> runtime;
|
|
33
|
+
{
|
|
34
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
35
|
+
runtime = std::move(runtime_);
|
|
36
|
+
}
|
|
37
|
+
if (runtime) {
|
|
38
|
+
runtime->destroy();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
void HybridKernelHost::start(double backgroundLockSeconds) {
|
|
43
|
+
// Compare as doubles before converting: casting NaN or an out-of-range value
|
|
44
|
+
// to int is undefined behaviour.
|
|
45
|
+
if (backgroundLockSeconds != 0 && backgroundLockSeconds != 60 &&
|
|
46
|
+
backgroundLockSeconds != 300 && backgroundLockSeconds != 900) {
|
|
47
|
+
throw std::runtime_error("backgroundLockSeconds must be one of 0, 60, 300, 900");
|
|
48
|
+
}
|
|
49
|
+
const int seconds = static_cast<int>(backgroundLockSeconds);
|
|
50
|
+
{
|
|
51
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
52
|
+
if (runtime_ || exited_) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// The platform observer installs once; this host registers its deadline so
|
|
57
|
+
// the dispatcher can stamp or lock it on background/foreground transitions.
|
|
58
|
+
qntx::installLifecycleObserver();
|
|
59
|
+
qntx::KernelLifecycleDispatcher::shared().registerHost(
|
|
60
|
+
std::dynamic_pointer_cast<HybridKernelHost>(shared_from_this()), seconds);
|
|
61
|
+
// Resolved once here: a platform failure rejects start() for the JS caller
|
|
62
|
+
// instead of surfacing later as a store error inside the kernel.
|
|
63
|
+
const std::string vaultDirectory = qntx::platformVaultDirectory();
|
|
64
|
+
|
|
65
|
+
std::weak_ptr<HybridKernelHost> weak =
|
|
66
|
+
std::dynamic_pointer_cast<HybridKernelHost>(shared_from_this());
|
|
67
|
+
qntx::KernelRuntime::Hooks hooks;
|
|
68
|
+
hooks.onPost = [weak](std::vector<uint8_t> bytes) {
|
|
69
|
+
if (const auto self = weak.lock()) {
|
|
70
|
+
std::function<void(const std::shared_ptr<ArrayBuffer>&)> listener;
|
|
71
|
+
{
|
|
72
|
+
std::lock_guard<std::mutex> lock(self->mutex_);
|
|
73
|
+
listener = self->messageListener_;
|
|
74
|
+
}
|
|
75
|
+
if (listener) {
|
|
76
|
+
listener(ArrayBuffer::copy(bytes.data(), bytes.size()));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
hooks.onError = [weak](std::string reason) {
|
|
81
|
+
logKernelCrash(reason);
|
|
82
|
+
if (const auto self = weak.lock()) {
|
|
83
|
+
self->exit(KernelExitReason::CRASHED);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
hooks.onIntegrity = [weak] {
|
|
87
|
+
if (const auto self = weak.lock()) {
|
|
88
|
+
self->exit(KernelExitReason::INTEGRITY);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const auto script = qntx::KernelScript{
|
|
93
|
+
qntx::KernelScript::Kind::bytecode,
|
|
94
|
+
std::make_shared<const std::vector<uint8_t>>(
|
|
95
|
+
qntx::KERNEL_BYTECODE, qntx::KERNEL_BYTECODE + qntx::KERNEL_BYTECODE_SIZE)};
|
|
96
|
+
const auto runtime =
|
|
97
|
+
qntx::KernelRuntime::start(script, std::move(hooks), vaultDirectory);
|
|
98
|
+
{
|
|
99
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
100
|
+
if (exited_) {
|
|
101
|
+
// An exit (integrity hash check raced a lock) already fired — drop the
|
|
102
|
+
// fresh runtime instead of resurrecting a dead host.
|
|
103
|
+
runtime->destroy();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
runtime_ = runtime;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
void HybridKernelHost::post(const std::shared_ptr<ArrayBuffer>& frame) {
|
|
111
|
+
if (frame->size() > kMaxFrameBytes) {
|
|
112
|
+
exit(KernelExitReason::INTEGRITY);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
std::shared_ptr<qntx::KernelRuntime> runtime;
|
|
116
|
+
{
|
|
117
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
118
|
+
runtime = runtime_;
|
|
119
|
+
}
|
|
120
|
+
if (runtime) {
|
|
121
|
+
const auto* data = static_cast<const uint8_t*>(frame->data());
|
|
122
|
+
runtime->post(std::vector<uint8_t>(data, data + frame->size()));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
void HybridKernelHost::onMessage(
|
|
127
|
+
const std::function<void(const std::shared_ptr<ArrayBuffer>&)>& listener) {
|
|
128
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
129
|
+
messageListener_ = listener;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
void HybridKernelHost::onExit(const std::function<void(KernelExitReason)>& listener) {
|
|
133
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
134
|
+
exitListener_ = listener;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
void HybridKernelHost::lock() {
|
|
138
|
+
exit(KernelExitReason::LOCKED);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
void HybridKernelHost::exit(KernelExitReason reason) {
|
|
142
|
+
std::shared_ptr<qntx::KernelRuntime> runtime;
|
|
143
|
+
std::function<void(KernelExitReason)> listener;
|
|
144
|
+
{
|
|
145
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
146
|
+
if (exited_) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
exited_ = true;
|
|
150
|
+
runtime = std::move(runtime_);
|
|
151
|
+
listener = exitListener_;
|
|
152
|
+
}
|
|
153
|
+
if (runtime) {
|
|
154
|
+
runtime->destroy();
|
|
155
|
+
}
|
|
156
|
+
qntx::KernelLifecycleDispatcher::shared().unregister(this);
|
|
157
|
+
if (listener) {
|
|
158
|
+
listener(reason);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
} // namespace margelo::nitro::qntxwallet
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include "HybridKernelHostSpec.hpp"
|
|
4
|
+
|
|
5
|
+
#include <functional>
|
|
6
|
+
#include <memory>
|
|
7
|
+
#include <mutex>
|
|
8
|
+
|
|
9
|
+
#include "host/Lifecycle.hpp"
|
|
10
|
+
|
|
11
|
+
#include "../runtime/KernelRuntime.hpp"
|
|
12
|
+
|
|
13
|
+
namespace margelo::nitro::qntxwallet {
|
|
14
|
+
|
|
15
|
+
// The only Nitro-registered object: drives one kernel runtime. `start`
|
|
16
|
+
// verifies the embedded bytecode's SHA-256 before loading; `post` enforces the
|
|
17
|
+
// 1 MiB frame cap (the inbound queue cap lives inside KernelRuntime::post and
|
|
18
|
+
// reports through the same integrity exit); `lock` destroys the runtime. Exit
|
|
19
|
+
// reasons reach JS through the `onExit` listener, at most once.
|
|
20
|
+
class HybridKernelHost final : public HybridKernelHostSpec,
|
|
21
|
+
public qntx::LifecycleLockTarget {
|
|
22
|
+
public:
|
|
23
|
+
HybridKernelHost() : HybridObject(TAG) {}
|
|
24
|
+
~HybridKernelHost() override;
|
|
25
|
+
|
|
26
|
+
void start(double backgroundLockSeconds) override;
|
|
27
|
+
// The platform's lifecycle dispatcher locks through here (main thread).
|
|
28
|
+
void lockForBackground() override { exit(KernelExitReason::BACKGROUND); }
|
|
29
|
+
void post(const std::shared_ptr<ArrayBuffer>& frame) override;
|
|
30
|
+
void onMessage(const std::function<void(const std::shared_ptr<ArrayBuffer>&)>& listener)
|
|
31
|
+
override;
|
|
32
|
+
void onExit(const std::function<void(KernelExitReason)>& listener) override;
|
|
33
|
+
void lock() override;
|
|
34
|
+
|
|
35
|
+
private:
|
|
36
|
+
static constexpr size_t kMaxFrameBytes = 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
// Reports `reason` through onExit once, then tears the runtime down unless
|
|
39
|
+
// the exit was itself the lock report (the runtime is already gone).
|
|
40
|
+
void exit(KernelExitReason reason);
|
|
41
|
+
|
|
42
|
+
std::mutex mutex_;
|
|
43
|
+
std::shared_ptr<qntx::KernelRuntime> runtime_;
|
|
44
|
+
std::function<void(const std::shared_ptr<ArrayBuffer>&)> messageListener_;
|
|
45
|
+
std::function<void(KernelExitReason)> exitListener_;
|
|
46
|
+
bool exited_ = false;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
} // namespace margelo::nitro::qntxwallet
|