Skip to content

Commit 3c81c68

Browse files
committed
Add cross-platform enhancements and legacy device credential support
Expanded Apple platform support in the podspec and Swift implementation to include macOS, watchOS, and visionOS. Refactored Android biometric authentication to support legacy device credential prompts on pre-Android 10 devices via a new DeviceCredentialPromptFragment. Improved coroutine handling, code documentation, and metadata consistency across platforms. Updated TypeScript types and documentation to clarify cross-platform behavior and security tier downgrades.
1 parent 0d901e1 commit 3c81c68

12 files changed

Lines changed: 409 additions & 119 deletions

File tree

SensitiveInfo.podspec

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ Pod::Spec.new do |s|
1010
s.license = package["license"]
1111
s.authors = package["author"]
1212

13-
s.platforms = { :ios => min_ios_version_supported, :visionos => 1.0 }
13+
s.platforms = {
14+
:ios => min_ios_version_supported,
15+
:osx => '11.0',
16+
:watchos => '7.0',
17+
:visionos => '1.0'
18+
}
1419
s.source = { :git => "https://github.com/mateusandrade/react-native-sensitive-info.git", :tag => "#{s.version}" }
1520

1621
s.source_files = [
@@ -27,5 +32,6 @@ Pod::Spec.new do |s|
2732

2833
s.dependency 'React-jsi'
2934
s.dependency 'React-callinvoker'
35+
s.frameworks = ['LocalAuthentication', 'Security']
3036
install_modules_dependencies(s)
3137
end

android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt renamed to android/src/main/java/com/margelo/nitro/sensitiveinfo/HybridSensitiveInfo.kt

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
package com.sensitiveinfo
1+
package com.margelo.nitro.sensitiveinfo
22

3+
import androidx.annotation.Keep
34
import com.margelo.nitro.core.Promise
45
import com.margelo.nitro.sensitiveinfo.AccessControl
56
import com.margelo.nitro.sensitiveinfo.AuthenticationPrompt
@@ -47,6 +48,7 @@ import kotlin.text.Charsets
4748
* The class resolves the appropriate storage backend, encrypts values with the Android Keystore,
4849
* and keeps metadata so JavaScript consumers always know which security tier saved an entry.
4950
*/
51+
@Keep
5052
class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
5153
private val applicationContext get() = ReactContextHolder.requireContext()
5254

@@ -57,21 +59,29 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
5759
private val authenticator by lazy { BiometricAuthenticator() }
5860
private val cryptoManager by lazy { CryptoManager(authenticator) }
5961

62+
private fun resolveService(service: String?): String = serviceResolver.resolve(service)
63+
64+
/** Dispatches a block to the IO dispatcher while keeping call-sites succinct. */
65+
private suspend fun <T> io(block: suspend () -> T): T = withContext(Dispatchers.IO) { block() }
66+
67+
/** Wrapper that fetches an entry from disk using the canonical dispatcher. */
68+
private suspend fun readEntry(service: String, key: String): PersistedEntry? = io {
69+
storage.read(service, key)
70+
}
71+
6072
/**
6173
* Encrypts and stores a secret for the requested service/key pair.
6274
*
6375
* @return Metadata describing the security level used, mirroring the JS `MutationResult` type.
6476
*/
6577
override fun setItem(request: SensitiveInfoSetRequest): Promise<MutationResult> {
6678
return Promise.async {
67-
val service = serviceResolver.resolve(request.service)
79+
val service = resolveService(request.service)
6880
val strongOnly = request.androidBiometricsStrongOnly == true
6981
val resolution = accessControlResolver.resolve(request.accessControl, strongOnly)
7082
val alias = AliasGenerator.create(service, resolution.signature)
7183

72-
val previousEntry = withContext(Dispatchers.IO) {
73-
storage.read(service, request.key)
74-
}
84+
val previousEntry = readEntry(service, request.key)
7585

7686
val metadata = StorageMetadata(
7787
securityLevel = resolution.securityLevel,
@@ -98,9 +108,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
98108
useStrongBox = resolution.useStrongBox
99109
)
100110

101-
withContext(Dispatchers.IO) {
102-
storage.save(service, request.key, persisted)
103-
}
111+
io { storage.save(service, request.key, persisted) }
104112

105113
if (previousEntry != null && previousEntry.alias != alias) {
106114
maybeDeleteAlias(service, previousEntry.alias)
@@ -116,9 +124,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
116124
override fun getItem(request: SensitiveInfoGetRequest): Promise<SensitiveInfoItem?> {
117125
return Promise.async {
118126
val includeValue = request.includeValue ?: true
119-
val service = serviceResolver.resolve(request.service)
120-
val entry = withContext(Dispatchers.IO) { storage.read(service, request.key) }
121-
?: throw SensitiveInfoException.NotFound(request.key, service)
127+
val service = resolveService(request.service)
128+
val entry = readEntry(service, request.key)
129+
?: throw SensitiveInfoException.NotFound(request.key, service)
122130

123131
val metadata = entry.metadata.toStorageMetadata() ?: fallbackMetadata(entry)
124132

@@ -142,9 +150,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
142150
*/
143151
override fun deleteItem(request: SensitiveInfoDeleteRequest): Promise<Boolean> {
144152
return Promise.async {
145-
val service = serviceResolver.resolve(request.service)
146-
val existing = withContext(Dispatchers.IO) { storage.read(service, request.key) }
147-
val removed = withContext(Dispatchers.IO) { storage.delete(service, request.key) }
153+
val service = resolveService(request.service)
154+
val existing = readEntry(service, request.key)
155+
val removed = io { storage.delete(service, request.key) }
148156
if (removed && existing != null) {
149157
maybeDeleteAlias(service, existing.alias)
150158
}
@@ -157,21 +165,23 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
157165
*/
158166
override fun hasItem(request: SensitiveInfoHasRequest): Promise<Boolean> {
159167
return Promise.async {
160-
val service = serviceResolver.resolve(request.service)
161-
withContext(Dispatchers.IO) {
162-
storage.contains(service, request.key)
163-
}
168+
val service = resolveService(request.service)
169+
io { storage.contains(service, request.key) }
164170
}
165171
}
166172

167173
/**
168-
* Enumerates every entry in a service. When `includeValues` is false the secrets stay encrypted.
174+
* Enumerates every entry in a service. When `includeValues` is false the secrets stay encrypted.
175+
*
176+
* ```ts
177+
* const items = await SensitiveInfo.getAllItems({ service: 'vault', includeValues: true })
178+
* ```
169179
*/
170180
override fun getAllItems(request: SensitiveInfoEnumerateRequest?): Promise<Array<SensitiveInfoItem>> {
171181
return Promise.async {
172182
val includeValues = request?.includeValues == true
173-
val service = serviceResolver.resolve(request?.service)
174-
val items = withContext(Dispatchers.IO) { storage.readAll(service) }
183+
val service = resolveService(request?.service)
184+
val items = io { storage.readAll(service) }
175185

176186
val result = items.mapNotNull { (key, entry) ->
177187
val metadata = entry.metadata.toStorageMetadata() ?: fallbackMetadata(entry)
@@ -198,11 +208,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
198208
*/
199209
override fun clearService(request: SensitiveInfoOptions?): Promise<Unit> {
200210
return Promise.async {
201-
val service = serviceResolver.resolve(request?.service)
202-
val existing = withContext(Dispatchers.IO) { storage.readAll(service) }
203-
withContext(Dispatchers.IO) {
204-
storage.clear(service)
205-
}
211+
val service = resolveService(request?.service)
212+
val existing = io { storage.readAll(service) }
213+
io { storage.clear(service) }
206214
existing.map { it.second.alias }
207215
.distinct()
208216
.forEach { alias -> cryptoManager.deleteKey(alias) }
@@ -220,6 +228,10 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
220228
return Promise.resolved(snapshot)
221229
}
222230

231+
/**
232+
* Rehydrates a ciphertext/IV pair using the alias captured in the persisted metadata. When the
233+
* item requires user presence the associated biometric/device-credential prompt is displayed.
234+
*/
223235
private suspend fun decryptValue(
224236
entry: PersistedEntry,
225237
metadata: StorageMetadata,
@@ -247,6 +259,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
247259
return String(decrypted, Charsets.UTF_8)
248260
}
249261

262+
/** Fallback metadata path used when a legacy record predates the richer JSON payload. */
250263
private fun fallbackMetadata(entry: PersistedEntry): StorageMetadata {
251264
val accessControl = accessControlFromPersisted(entry.metadata.accessControl) ?: AccessControl.NONE
252265
val backend = storageBackendFromPersisted(entry.metadata.backend) ?: StorageBackend.ANDROIDKEYSTORE
@@ -270,8 +283,13 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
270283

271284
private fun nowSeconds(): Double = System.currentTimeMillis() / 1000.0
272285

286+
/**
287+
* Deletes the keystore alias once there are no persisted entries referencing it anymore. We
288+
* keep this work off the main thread as SharedPreferences iteration can be slow on older
289+
* devices.
290+
*/
273291
private suspend fun maybeDeleteAlias(service: String, alias: String) {
274-
val remaining = withContext(Dispatchers.IO) { storage.readAll(service) }
292+
val remaining = io { storage.readAll(service) }
275293
val stillReferenced = remaining.any { (_, entry) -> entry.alias == alias }
276294
if (!stillReferenced) {
277295
cryptoManager.deleteKey(alias)

android/src/main/java/com/sensitiveinfo/SensitiveInfoPackage.kt

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
package com.sensitiveinfo
22

3-
import com.facebook.react.TurboReactPackage
43
import com.facebook.react.bridge.NativeModule
54
import com.facebook.react.bridge.ReactApplicationContext
6-
import com.facebook.react.module.model.ReactModuleInfoProvider
5+
import com.facebook.react.ReactPackage
76
import com.facebook.react.uimanager.ViewManager
87
import com.margelo.nitro.sensitiveinfo.SensitiveInfoOnLoad
98
import com.sensitiveinfo.internal.util.ReactContextHolder
109

11-
class SensitiveInfoPackage : TurboReactPackage() {
12-
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
10+
class SensitiveInfoPackage : ReactPackage {
11+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
1312
ReactContextHolder.install(reactContext)
14-
return null
13+
return emptyList()
1514
}
1615

17-
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { emptyMap() }
18-
1916
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
2017
ReactContextHolder.install(reactContext)
2118
return emptyList()

android/src/main/java/com/sensitiveinfo/internal/auth/BiometricAuthenticator.kt

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
1111
import kotlinx.coroutines.withContext
1212
import com.sensitiveinfo.internal.util.ReactContextHolder
1313
import javax.crypto.Cipher
14+
import kotlin.coroutines.cancellation.CancellationException
1415
import kotlin.coroutines.resume
1516
import kotlin.coroutines.resumeWithException
1617

@@ -22,55 +23,107 @@ import kotlin.coroutines.resumeWithException
2223
* the surface used by the Nitro Promise bridge.
2324
*/
2425
internal class BiometricAuthenticator {
26+
private val applicationContext get() = ReactContextHolder.requireContext()
27+
28+
/**
29+
* Prompts the user for biometric/device-credential authentication and returns the cipher once it
30+
* can be used. The coroutine cooperatively cancels when the caller abandons the operation.
31+
*/
2532
suspend fun authenticate(
2633
prompt: AuthenticationPrompt?,
2734
allowedAuthenticators: Int,
28-
cipher: Cipher
29-
): Cipher {
35+
cipher: Cipher?
36+
): Cipher? {
3037
val activity = currentFragmentActivity()
31-
return withContext(Dispatchers.Main) {
32-
suspendCancellableCoroutine { continuation ->
33-
val executor = ContextCompat.getMainExecutor(activity)
34-
val promptInfo = buildPromptInfo(prompt, allowedAuthenticators)
35-
val biometricPrompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
36-
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
37-
val authCipher = result.cryptoObject?.cipher
38-
?: return continuation.resumeWithException(IllegalStateException("Missing cipher from authentication result."))
39-
continuation.resume(authCipher)
40-
}
38+
val effectivePrompt = prompt ?: AuthenticationPrompt(DEFAULT_TITLE, null, null, DEFAULT_CANCEL)
39+
val allowDeviceCredential = allowedAuthenticators and BiometricManager.Authenticators.DEVICE_CREDENTIAL != 0
40+
val supportsInlineDeviceCredential = allowDeviceCredential && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
41+
val allowLegacyDeviceCredential = allowDeviceCredential && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
4142

42-
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
43-
if (errorCode == BiometricPrompt.ERROR_CANCELED ||
44-
errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
45-
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON
46-
) {
47-
continuation.cancel()
48-
} else {
49-
continuation.resumeWithException(IllegalStateException(errString.toString()))
43+
return withContext(Dispatchers.Main) {
44+
if (cipher == null && allowLegacyDeviceCredential && !canUseBiometric()) {
45+
if (DeviceCredentialPromptFragment.authenticate(activity, effectivePrompt)) {
46+
cipher
47+
} else {
48+
throw IllegalStateException("Device credential authentication canceled.")
49+
}
50+
} else {
51+
try {
52+
authenticateWithBiometricPrompt(
53+
activity = activity,
54+
prompt = effectivePrompt,
55+
allowedAuthenticators = allowedAuthenticators,
56+
supportsInlineDeviceCredential = supportsInlineDeviceCredential,
57+
cipher = cipher
58+
)
59+
} catch (error: Throwable) {
60+
if (error is CancellationException) throw error
61+
if (allowLegacyDeviceCredential) {
62+
if (DeviceCredentialPromptFragment.authenticate(activity, effectivePrompt)) {
63+
return@withContext cipher
5064
}
5165
}
66+
throw error
67+
}
68+
}
69+
}
70+
}
71+
72+
private suspend fun authenticateWithBiometricPrompt(
73+
activity: FragmentActivity,
74+
prompt: AuthenticationPrompt,
75+
allowedAuthenticators: Int,
76+
supportsInlineDeviceCredential: Boolean,
77+
cipher: Cipher?
78+
): Cipher? {
79+
return suspendCancellableCoroutine { continuation ->
80+
val executor = ContextCompat.getMainExecutor(activity)
81+
val promptInfo = buildPromptInfo(prompt, allowedAuthenticators, supportsInlineDeviceCredential)
82+
val biometricPrompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
83+
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
84+
val authCipher = result.cryptoObject?.cipher ?: cipher
85+
continuation.resume(authCipher)
86+
}
5287

53-
override fun onAuthenticationFailed() {
54-
// Keep waiting for a successful attempt.
88+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
89+
if (errorCode == BiometricPrompt.ERROR_CANCELED ||
90+
errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
91+
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON
92+
) {
93+
continuation.cancel()
94+
} else {
95+
continuation.resumeWithException(IllegalStateException(errString.toString()))
5596
}
56-
})
97+
}
5798

58-
continuation.invokeOnCancellation {
59-
biometricPrompt.cancelAuthentication()
99+
override fun onAuthenticationFailed() {
100+
// Keep waiting for another attempt.
60101
}
102+
})
103+
104+
continuation.invokeOnCancellation {
105+
biometricPrompt.cancelAuthentication()
106+
}
61107

108+
if (cipher != null) {
62109
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
63110
biometricPrompt.authenticate(promptInfo, cryptoObject)
111+
} else {
112+
biometricPrompt.authenticate(promptInfo)
64113
}
65114
}
66115
}
67116

68-
private fun buildPromptInfo(prompt: AuthenticationPrompt?, allowedAuthenticators: Int): BiometricPrompt.PromptInfo {
117+
private fun buildPromptInfo(
118+
prompt: AuthenticationPrompt,
119+
allowedAuthenticators: Int,
120+
supportsInlineDeviceCredential: Boolean
121+
): BiometricPrompt.PromptInfo {
69122
val builder = BiometricPrompt.PromptInfo.Builder()
70-
.setTitle(prompt?.title ?: DEFAULT_TITLE)
123+
.setTitle(prompt.title)
71124

72-
prompt?.subtitle?.let(builder::setSubtitle)
73-
prompt?.description?.let(builder::setDescription)
125+
prompt.subtitle?.let(builder::setSubtitle)
126+
prompt.description?.let(builder::setDescription)
74127

75128
var promptAuthenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
76129
allowedAuthenticators
@@ -87,20 +140,35 @@ internal class BiometricAuthenticator {
87140
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
88141
builder.setAllowedAuthenticators(promptAuthenticators)
89142
if (!allowsDeviceCredential) {
90-
builder.setNegativeButtonText(prompt?.cancel ?: DEFAULT_CANCEL)
143+
builder.setNegativeButtonText(prompt.cancel ?: DEFAULT_CANCEL)
91144
}
92145
} else {
93-
if (allowsDeviceCredential) {
146+
if (allowsDeviceCredential && supportsInlineDeviceCredential && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
94147
@Suppress("DEPRECATION")
95148
builder.setDeviceCredentialAllowed(true)
96149
} else {
97-
builder.setNegativeButtonText(prompt?.cancel ?: DEFAULT_CANCEL)
150+
builder.setNegativeButtonText(prompt.cancel ?: DEFAULT_CANCEL)
98151
}
99152
}
100153

101154
return builder.build()
102155
}
103156

157+
private fun canUseBiometric(): Boolean {
158+
val biometricManager = BiometricManager.from(applicationContext)
159+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
160+
val strong = biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
161+
if (strong == BiometricManager.BIOMETRIC_SUCCESS) {
162+
true
163+
} else {
164+
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
165+
}
166+
} else {
167+
@Suppress("DEPRECATION")
168+
biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
169+
}
170+
}
171+
104172
private fun currentFragmentActivity(): FragmentActivity {
105173
val activity = ReactContextHolder.currentActivity()
106174
?: throw IllegalStateException("Unable to show authentication prompt: no active React activity.")

0 commit comments

Comments
 (0)