@qntx/wallet-native 0.0.0-stage → 0.5.2

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 (71) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +123 -2
  4. package/WalletNative.podspec +74 -0
  5. package/android/CMakeLists.txt +99 -0
  6. package/android/build.gradle +177 -0
  7. package/android/consumer-rules.pro +4 -0
  8. package/android/src/main/AndroidManifest.xml +2 -0
  9. package/android/src/main/cpp/cpp-adapter.cpp +29 -0
  10. package/android/src/main/java/com/margelo/nitro/qntxwallet/AppLifecycle.kt +35 -0
  11. package/android/src/main/java/com/margelo/nitro/qntxwallet/KernelPbkdf2.java +59 -0
  12. package/android/src/main/java/com/margelo/nitro/qntxwallet/Keystore.kt +280 -0
  13. package/android/src/main/java/com/margelo/nitro/qntxwallet/Passkey.kt +147 -0
  14. package/android/src/main/java/com/margelo/nitro/qntxwallet/VaultDirectory.kt +28 -0
  15. package/android/src/main/java/com/margelo/nitro/qntxwallet/WalletNativePackage.java +30 -0
  16. package/cpp/host/HybridKernelHost.cpp +162 -0
  17. package/cpp/host/HybridKernelHost.hpp +49 -0
  18. package/cpp/host/KernelFile.cpp +120 -0
  19. package/cpp/host/KernelFile.hpp +24 -0
  20. package/cpp/host/Keystore.hpp +70 -0
  21. package/cpp/host/Lifecycle.cpp +77 -0
  22. package/cpp/host/Lifecycle.hpp +71 -0
  23. package/cpp/host/Passkey.hpp +39 -0
  24. package/cpp/host/VaultDirectory.hpp +16 -0
  25. package/cpp/platform/KeystoreAndroid.cpp +175 -0
  26. package/cpp/platform/KeystoreIos.mm +300 -0
  27. package/cpp/platform/LifecycleAndroid.cpp +55 -0
  28. package/cpp/platform/LifecycleIos.mm +36 -0
  29. package/cpp/platform/PasskeyAndroid.cpp +118 -0
  30. package/cpp/platform/PasskeyIos.mm +20 -0
  31. package/cpp/platform/VaultDirectory.mm +58 -0
  32. package/cpp/platform/VaultDirectoryAndroid.cpp +35 -0
  33. package/cpp/runtime/KernelBackends.cpp +297 -0
  34. package/cpp/runtime/KernelBackends.hpp +20 -0
  35. package/cpp/runtime/KernelBuffer.cpp +12 -0
  36. package/cpp/runtime/KernelBuffer.hpp +96 -0
  37. package/cpp/runtime/KernelGlobals.cpp +535 -0
  38. package/cpp/runtime/KernelGlobals.hpp +107 -0
  39. package/cpp/runtime/KernelJs.cpp +155 -0
  40. package/cpp/runtime/KernelJs.hpp +81 -0
  41. package/cpp/runtime/KernelKeystore.cpp +224 -0
  42. package/cpp/runtime/KernelKeystore.hpp +28 -0
  43. package/cpp/runtime/KernelPasskey.cpp +137 -0
  44. package/cpp/runtime/KernelPasskey.hpp +23 -0
  45. package/cpp/runtime/KernelRuntime.cpp +496 -0
  46. package/cpp/runtime/KernelRuntime.hpp +94 -0
  47. package/cpp/runtime/platform/KernelPbkdf2.cpp +198 -0
  48. package/cpp/runtime/platform/KernelPbkdf2.hpp +38 -0
  49. package/dist/index.d.mts +44 -0
  50. package/dist/index.mjs +170 -0
  51. package/dist/index.mjs.map +1 -0
  52. package/kernel/generated/kernel-entry.js +42967 -0
  53. package/kernel/generated/kernel.manifest.json +80 -0
  54. package/nitrogen/generated/.gitattributes +1 -0
  55. package/nitrogen/generated/android/WalletNative+autolinking.cmake +81 -0
  56. package/nitrogen/generated/android/WalletNative+autolinking.gradle +27 -0
  57. package/nitrogen/generated/android/WalletNativeOnLoad.cpp +49 -0
  58. package/nitrogen/generated/android/WalletNativeOnLoad.hpp +34 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/qntxwallet/WalletNativeOnLoad.kt +35 -0
  60. package/nitrogen/generated/ios/WalletNative+autolinking.rb +62 -0
  61. package/nitrogen/generated/ios/WalletNative-Swift-Cxx-Bridge.cpp +17 -0
  62. package/nitrogen/generated/ios/WalletNative-Swift-Cxx-Bridge.hpp +27 -0
  63. package/nitrogen/generated/ios/WalletNative-Swift-Cxx-Umbrella.hpp +38 -0
  64. package/nitrogen/generated/ios/WalletNativeAutolinking.mm +35 -0
  65. package/nitrogen/generated/ios/WalletNativeAutolinking.swift +16 -0
  66. package/nitrogen/generated/shared/c++/HybridKernelHostSpec.cpp +25 -0
  67. package/nitrogen/generated/shared/c++/HybridKernelHostSpec.hpp +69 -0
  68. package/nitrogen/generated/shared/c++/KernelExitReason.hpp +84 -0
  69. package/package.json +65 -4
  70. package/react-native.config.js +14 -0
  71. package/scripts/compile-kernel.mjs +118 -0
@@ -0,0 +1,177 @@
1
+ buildscript {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ }
6
+ dependencies {
7
+
8
+ classpath "com.android.tools.build:gradle:9.2.1"
9
+ }
10
+ }
11
+
12
+ def reactNativeArchitectures() {
13
+ def value = rootProject.getProperties().get("reactNativeArchitectures")
14
+ return value ? value.split(",") : ["x86", "x86_64", "armeabi-v7a", "arm64-v8a"]
15
+ }
16
+
17
+ def isNewArchitectureEnabled() {
18
+ return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
19
+ }
20
+
21
+ apply plugin: "com.android.library"
22
+ apply plugin: 'org.jetbrains.kotlin.android'
23
+ apply from: '../nitrogen/generated/android/WalletNative+autolinking.gradle'
24
+
25
+ if (isNewArchitectureEnabled()) {
26
+ apply plugin: "com.facebook.react"
27
+
28
+
29
+ }
30
+
31
+ def getExtOrDefault(name) {
32
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["WalletNative_" + name]
33
+ }
34
+
35
+ def getExtOrIntegerDefault(name) {
36
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["WalletNative_" + name]).toInteger()
37
+ }
38
+
39
+ android {
40
+ namespace = "com.margelo.nitro.qntxwallet"
41
+
42
+ ndkVersion = getExtOrDefault("ndkVersion")
43
+ compileSdkVersion = getExtOrIntegerDefault("compileSdkVersion")
44
+
45
+ defaultConfig {
46
+ // WD-024: per-use Keystore keys that accept a biometric or the device
47
+ // credential through BiometricPrompt need API 30.
48
+ minSdkVersion = 30
49
+ targetSdkVersion = getExtOrIntegerDefault("targetSdkVersion")
50
+
51
+ consumerProguardFiles "consumer-rules.pro"
52
+
53
+ externalNativeBuild {
54
+ cmake {
55
+ cppFlags "-frtti -fexceptions -Wall -Wextra -fstack-protector-all"
56
+ // The consuming app's react-native root — resolved through node module
57
+ // resolution from the app project (as RN's own settings.gradle does);
58
+ // compile-kernel.mjs then resolves hermesc through it.
59
+ arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
60
+ // The consuming app's react-native dir, resolved through node
61
+ // module resolution from the app project — a library cannot reach
62
+ // it by walking up from its own tree.
63
+ "-DQNTX_REACT_NATIVE_PATH=" + providers.exec {
64
+ workingDir = rootProject.projectDir
65
+ commandLine "node", "-p", "require('path').dirname(require.resolve('react-native/package.json'))"
66
+ }.standardOutput.asText.get().trim(),
67
+ // The app's @qntx/spark-native dir (header + per-ABI libqntx_spark.so),
68
+ // resolved the same way.
69
+ "-DQNTX_SPARK_NATIVE_PATH=" + providers.exec {
70
+ workingDir = rootProject.projectDir
71
+ commandLine "node", "-p", "require('path').dirname(require.resolve('@qntx/spark-native/package.json'))"
72
+ }.standardOutput.asText.get().trim()
73
+ abiFilters (*reactNativeArchitectures())
74
+ buildTypes {
75
+ debug {
76
+ cppFlags "-O1 -g"
77
+ }
78
+ release {
79
+ cppFlags "-O2"
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ externalNativeBuild {
87
+ cmake {
88
+ path = file("CMakeLists.txt")
89
+ }
90
+ }
91
+
92
+ packagingOptions {
93
+ excludes = [
94
+ "META-INF",
95
+ "META-INF/**",
96
+ "**/libNitroModules.so",
97
+ "**/libc++_shared.so",
98
+ "**/libfbjni.so",
99
+ "**/libjsi.so",
100
+ "**/libfolly_json.so",
101
+ "**/libfolly_runtime.so",
102
+ "**/libglog.so",
103
+ "**/libhermes.so",
104
+ "**/libhermes-executor-debug.so",
105
+ "**/libhermes_executor.so",
106
+ "**/libreactnative.so",
107
+ "**/libreactnativejni.so",
108
+ "**/libturbomodulejsijni.so",
109
+ "**/libreact_nativemodule_core.so",
110
+ "**/libjscexecutor.so",
111
+ // Linked, not bundled — @qntx/spark-native packages it from its jniLibs.
112
+ "**/libqntx_spark.so"
113
+ ]
114
+ }
115
+
116
+ buildFeatures {
117
+ buildConfig = true
118
+ prefab = true
119
+ }
120
+
121
+ buildTypes {
122
+ release {
123
+ minifyEnabled = false
124
+ }
125
+ }
126
+
127
+ lintOptions {
128
+ disable "GradleCompatible"
129
+ }
130
+
131
+ compileOptions {
132
+ sourceCompatibility JavaVersion.VERSION_1_8
133
+ targetCompatibility JavaVersion.VERSION_1_8
134
+ }
135
+
136
+ sourceSets {
137
+ main {
138
+ if (isNewArchitectureEnabled()) {
139
+ java.srcDirs += [
140
+ "${project.buildDir}/generated/source/codegen/java"
141
+ ]
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ dependencies {
148
+ // For > 0.71, this is replaced by `com.facebook.react:react-android:$version` by the React
149
+ // gradle plugin — it supplies the fbjni/ReactAndroid prefab packages the generated CMake
150
+ // autolinking file finds with find_package.
151
+ //noinspection GradleDynamicVersion
152
+ implementation "com.facebook.react:react-native:+"
153
+
154
+ // Hermes engine prefab — the kernel runtime links hermesvm/libhermes
155
+ implementation "com.facebook.react:hermes-android"
156
+
157
+ // NitroModules core (provides the react-native-nitro-modules prefab + HybridObject runtime)
158
+ implementation project(":react-native-nitro-modules")
159
+ implementation project(":qntx_nostr-native")
160
+
161
+ // ProcessLifecycleOwner for the background lock (W7) — the version the app
162
+ // already resolves for androidx.lifecycle.
163
+ implementation "androidx.lifecycle:lifecycle-process:2.6.2"
164
+
165
+ // BiometricPrompt + CryptoObject for the hardware vault factor (W5).
166
+ implementation "androidx.biometric:biometric:1.1.0"
167
+
168
+ // Credential Manager for native passkeys (W6); the Play services provider
169
+ // backs Google Password Manager.
170
+ implementation "androidx.credentials:credentials:1.6.0"
171
+ implementation "androidx.credentials:credentials-play-services-auth:1.6.0"
172
+ }
173
+
174
+ repositories {
175
+ mavenCentral()
176
+ google()
177
+ }
@@ -0,0 +1,4 @@
1
+ # Called from native code over JNI or bound to JNI by method-name mangling;
2
+ # R8 would otherwise strip or rename them (the classes have no direct Java
3
+ # call sites inside the app).
4
+ -keep class com.margelo.nitro.qntxwallet.** { *; }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,29 @@
1
+ #include "WalletNativeOnLoad.hpp"
2
+ #include "host/VaultDirectory.hpp"
3
+ #include "runtime/platform/KernelPbkdf2.hpp"
4
+ #include <fbjni/fbjni.h>
5
+ #include <jni.h>
6
+
7
+ namespace qntx {
8
+ void kernelVaultDirectoryInit(facebook::jni::alias_ref<jclass> clazz);
9
+ void kernelLifecycleInit(facebook::jni::alias_ref<jclass> clazz);
10
+ void kernelKeystoreInit(facebook::jni::alias_ref<jclass> clazz);
11
+ void kernelPasskeyInit(facebook::jni::alias_ref<jclass> clazz);
12
+ }
13
+
14
+ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
15
+ return facebook::jni::initialize(vm, []() {
16
+ qntx::kernelVaultDirectoryInit(
17
+ facebook::jni::findClassLocal("com/margelo/nitro/qntxwallet/VaultDirectory"));
18
+ qntx::kernelLifecycleInit(
19
+ facebook::jni::findClassLocal("com/margelo/nitro/qntxwallet/AppLifecycle"));
20
+ qntx::kernelKeystoreInit(
21
+ facebook::jni::findClassLocal("com/margelo/nitro/qntxwallet/Keystore"));
22
+ qntx::kernelPasskeyInit(
23
+ facebook::jni::findClassLocal("com/margelo/nitro/qntxwallet/Passkey"));
24
+ margelo::nitro::qntxwallet::registerAllNatives();
25
+ qntx::kernelPbkdf2OnLoad(
26
+ facebook::jni::Environment::current(),
27
+ "com/margelo/nitro/qntxwallet/KernelPbkdf2");
28
+ });
29
+ }
@@ -0,0 +1,35 @@
1
+ package com.margelo.nitro.qntxwallet
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import androidx.annotation.Keep
6
+ import androidx.lifecycle.DefaultLifecycleObserver
7
+ import androidx.lifecycle.LifecycleOwner
8
+ import androidx.lifecycle.ProcessLifecycleOwner
9
+ import com.facebook.proguard.annotations.DoNotStrip
10
+
11
+ /**
12
+ * Process-wide lifecycle: the C++ host calls [install] once (fbjni, first
13
+ * KernelHost.start); the observer forwards app background/foreground
14
+ * transitions to the native dispatcher on the main looper.
15
+ */
16
+ @DoNotStrip
17
+ @Keep
18
+ object AppLifecycle {
19
+
20
+ @JvmStatic
21
+ fun install() {
22
+ Handler(Looper.getMainLooper()).post {
23
+ ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
24
+ override fun onStop(owner: LifecycleOwner) = onAppBackground()
25
+ override fun onStart(owner: LifecycleOwner) = onAppForeground()
26
+ })
27
+ }
28
+ }
29
+
30
+ @JvmStatic
31
+ private external fun onAppBackground()
32
+
33
+ @JvmStatic
34
+ private external fun onAppForeground()
35
+ }
@@ -0,0 +1,59 @@
1
+ package com.margelo.nitro.qntxwallet;
2
+
3
+ import java.security.GeneralSecurityException;
4
+ import java.util.Arrays;
5
+ import javax.crypto.Mac;
6
+ import javax.crypto.spec.SecretKeySpec;
7
+
8
+ /**
9
+ * PBKDF2 for the kernel runtime, called from C++ over JNI on the dedicated KDF worker thread.
10
+ *
11
+ * Byte-exact RFC 8018 derivation over javax.crypto.Mac — there is no SecretKeyFactory/PBEKeySpec
12
+ * path, because chars decoded from the password's UTF-8 bytes are provider-specific. All
13
+ * intermediate arrays the VM hands back (u, t, out) are wiped. Note that SecretKeySpec and JCA
14
+ * provider internals retain their own non-wipeable copies of the key material; the caller-side
15
+ * buffers are still zeroed by JniByteArray after return.
16
+ */
17
+ public final class KernelPbkdf2 {
18
+ private KernelPbkdf2() {}
19
+
20
+ public static byte[] derive(String hmacAlgorithm, byte[] password, byte[] salt, int iterations, int length)
21
+ throws GeneralSecurityException {
22
+ SecretKeySpec key = new SecretKeySpec(password, hmacAlgorithm);
23
+ Mac mac = Mac.getInstance(hmacAlgorithm);
24
+ mac.init(key);
25
+ int blockSize = mac.getMacLength();
26
+ int blocks = (length + blockSize - 1) / blockSize;
27
+ byte[] out = new byte[blocks * blockSize];
28
+ byte[] index = new byte[4];
29
+ int offset = 0;
30
+ try {
31
+ for (int block = 1; block <= blocks; block++) {
32
+ index[0] = (byte) (block >>> 24);
33
+ index[1] = (byte) (block >>> 16);
34
+ index[2] = (byte) (block >>> 8);
35
+ index[3] = (byte) block;
36
+ mac.update(salt);
37
+ byte[] u = mac.doFinal(index);
38
+ byte[] t = u.clone();
39
+ for (int j = 1; j < iterations; j++) {
40
+ byte[] next = mac.doFinal(u);
41
+ Arrays.fill(u, (byte) 0);
42
+ u = next;
43
+ for (int k = 0; k < u.length; k++) {
44
+ t[k] ^= u[k];
45
+ }
46
+ }
47
+ System.arraycopy(t, 0, out, offset, blockSize);
48
+ offset += blockSize;
49
+ Arrays.fill(u, (byte) 0);
50
+ Arrays.fill(t, (byte) 0);
51
+ }
52
+ return Arrays.copyOf(out, length);
53
+ } finally {
54
+ Arrays.fill(out, (byte) 0);
55
+ Arrays.fill(index, (byte) 0);
56
+ Arrays.fill(password, (byte) 0);
57
+ }
58
+ }
59
+ }
@@ -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
+ }