Skip to content

Commit 676034b

Browse files
committed
Add Android secure storage with biometric support
Introduces a new Android implementation for secure storage using the Android Keystore, supporting biometric authentication and device credentials. Adds internal modules for cryptography, storage, and utility functions, and updates the main HybridSensitiveInfo module to use these. Updates dependencies to include androidx.biometric. Refactors package initialization to manage React context. Example app minor cleanup.
1 parent 9658baa commit 676034b

26 files changed

Lines changed: 4074 additions & 112 deletions

android/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ dependencies {
137137

138138
// Add a dependency on NitroModules
139139
implementation project(":react-native-nitro-modules")
140+
141+
implementation "androidx.biometric:biometric:1.1.0"
140142
}
141143

142144
if (isNewArchitectureEnabled()) {
Lines changed: 237 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,244 @@
11
package com.sensitiveinfo
22

3-
import android.graphics.Color
4-
import android.view.View
5-
import androidx.annotation.Keep
6-
import com.facebook.proguard.annotations.DoNotStrip
7-
import com.facebook.react.uimanager.ThemedReactContext
3+
import com.margelo.nitro.core.Promise
4+
import com.margelo.nitro.sensitiveinfo.AccessControl
5+
import com.margelo.nitro.sensitiveinfo.AuthenticationPrompt
86
import com.margelo.nitro.sensitiveinfo.HybridSensitiveInfoSpec
7+
import com.margelo.nitro.sensitiveinfo.MutationResult
8+
import com.margelo.nitro.sensitiveinfo.SecurityAvailability
9+
import com.margelo.nitro.sensitiveinfo.SecurityLevel
10+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoDeleteRequest
11+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoEnumerateRequest
12+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoGetRequest
13+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoHasRequest
14+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoItem
15+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoOptions
16+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoSetRequest
17+
import com.margelo.nitro.sensitiveinfo.StorageBackend
18+
import com.margelo.nitro.sensitiveinfo.StorageMetadata
19+
import com.sensitiveinfo.internal.auth.BiometricAuthenticator
20+
import com.sensitiveinfo.internal.crypto.AccessControlResolver
21+
import com.sensitiveinfo.internal.crypto.CryptoManager
22+
import com.sensitiveinfo.internal.crypto.SecurityAvailabilityResolver
23+
import com.sensitiveinfo.internal.storage.PersistedEntry
24+
import com.sensitiveinfo.internal.storage.PersistedMetadata
25+
import com.sensitiveinfo.internal.storage.SecureStorage
26+
import com.sensitiveinfo.internal.util.AliasGenerator
27+
import com.sensitiveinfo.internal.util.ReactContextHolder
28+
import com.sensitiveinfo.internal.util.ServiceNameResolver
29+
import com.sensitiveinfo.internal.util.accessControlFromPersisted
30+
import com.sensitiveinfo.internal.util.storageBackendFromPersisted
31+
import kotlinx.coroutines.Dispatchers
32+
import kotlinx.coroutines.withContext
33+
import kotlin.text.Charsets
934

10-
@Keep
11-
@DoNotStrip
12-
class HybridSensitiveInfo(val context: ThemedReactContext): HybridSensitiveInfoSpec() {
13-
// View
14-
override val view: View = View(context)
15-
16-
// Props
17-
private var _isRed = false
18-
override var isRed: Boolean
19-
get() = _isRed
20-
set(value) {
21-
_isRed = value
22-
view.setBackgroundColor(
23-
if (value) Color.RED
24-
else Color.BLACK
35+
class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
36+
private val applicationContext get() = ReactContextHolder.requireContext()
37+
38+
private val storage by lazy { SecureStorage(applicationContext) }
39+
private val serviceResolver by lazy { ServiceNameResolver(applicationContext) }
40+
private val availabilityResolver by lazy { SecurityAvailabilityResolver(applicationContext) }
41+
private val accessControlResolver by lazy { AccessControlResolver(availabilityResolver) }
42+
private val authenticator by lazy { BiometricAuthenticator() }
43+
private val cryptoManager by lazy { CryptoManager(authenticator) }
44+
45+
override fun setItem(request: SensitiveInfoSetRequest): Promise<MutationResult> {
46+
return Promise.async {
47+
val service = serviceResolver.resolve(request.service)
48+
val strongOnly = request.androidBiometricsStrongOnly == true
49+
val resolution = accessControlResolver.resolve(request.accessControl, strongOnly)
50+
val alias = AliasGenerator.create(service, resolution.signature)
51+
52+
val previousEntry = withContext(Dispatchers.IO) {
53+
storage.read(service, request.key)
54+
}
55+
56+
val metadata = StorageMetadata(
57+
securityLevel = resolution.securityLevel,
58+
backend = StorageBackend.ANDROIDKEYSTORE,
59+
accessControl = resolution.accessControl,
60+
timestamp = nowSeconds()
61+
)
62+
63+
val encryptionResult = cryptoManager.encrypt(
64+
alias = alias,
65+
plaintext = request.value.toByteArray(Charsets.UTF_8),
66+
resolution = resolution,
67+
prompt = request.authenticationPrompt
68+
)
69+
70+
val persisted = PersistedEntry(
71+
alias = alias,
72+
ciphertext = encryptionResult.ciphertext,
73+
iv = encryptionResult.iv,
74+
metadata = PersistedMetadata.from(metadata),
75+
authenticators = resolution.allowedAuthenticators,
76+
requiresAuthentication = resolution.requiresAuthentication,
77+
invalidateOnEnrollment = resolution.invalidateOnEnrollment,
78+
useStrongBox = resolution.useStrongBox
2579
)
80+
81+
withContext(Dispatchers.IO) {
82+
storage.save(service, request.key, persisted)
83+
}
84+
85+
if (previousEntry != null && previousEntry.alias != alias) {
86+
maybeDeleteAlias(service, previousEntry.alias)
87+
}
88+
89+
MutationResult(metadata)
90+
}
91+
}
92+
93+
override fun getItem(request: SensitiveInfoGetRequest): Promise<SensitiveInfoItem?> {
94+
return Promise.async {
95+
val includeValue = request.includeValue ?: true
96+
val service = serviceResolver.resolve(request.service)
97+
val entry = withContext(Dispatchers.IO) { storage.read(service, request.key) } ?: return@async null
98+
99+
val metadata = entry.metadata.toStorageMetadata() ?: fallbackMetadata(entry)
100+
101+
val value = if (includeValue) {
102+
decryptValue(entry, metadata, request.authenticationPrompt)
103+
} else {
104+
null
105+
}
106+
107+
SensitiveInfoItem(
108+
key = request.key,
109+
service = service,
110+
value = value,
111+
metadata = metadata
112+
)
113+
}
114+
}
115+
116+
override fun deleteItem(request: SensitiveInfoDeleteRequest): Promise<Boolean> {
117+
return Promise.async {
118+
val service = serviceResolver.resolve(request.service)
119+
val existing = withContext(Dispatchers.IO) { storage.read(service, request.key) }
120+
val removed = withContext(Dispatchers.IO) { storage.delete(service, request.key) }
121+
if (removed && existing != null) {
122+
maybeDeleteAlias(service, existing.alias)
123+
}
124+
removed
125+
}
126+
}
127+
128+
override fun hasItem(request: SensitiveInfoHasRequest): Promise<Boolean> {
129+
return Promise.async {
130+
val service = serviceResolver.resolve(request.service)
131+
withContext(Dispatchers.IO) {
132+
storage.contains(service, request.key)
133+
}
134+
}
135+
}
136+
137+
override fun getAllItems(request: SensitiveInfoEnumerateRequest?): Promise<Array<SensitiveInfoItem>> {
138+
return Promise.async {
139+
val includeValues = request?.includeValues == true
140+
val service = serviceResolver.resolve(request?.service)
141+
val items = withContext(Dispatchers.IO) { storage.readAll(service) }
142+
143+
val result = items.mapNotNull { (key, entry) ->
144+
val metadata = entry.metadata.toStorageMetadata() ?: fallbackMetadata(entry)
145+
val value = if (includeValues) {
146+
decryptValue(entry, metadata, request?.authenticationPrompt)
147+
} else {
148+
null
149+
}
150+
151+
SensitiveInfoItem(
152+
key = key,
153+
service = service,
154+
value = value,
155+
metadata = metadata
156+
)
157+
}
158+
159+
result.toTypedArray()
160+
}
161+
}
162+
163+
override fun clearService(request: SensitiveInfoOptions?): Promise<Unit> {
164+
return Promise.async {
165+
val service = serviceResolver.resolve(request?.service)
166+
val existing = withContext(Dispatchers.IO) { storage.readAll(service) }
167+
withContext(Dispatchers.IO) {
168+
storage.clear(service)
169+
}
170+
existing.map { it.second.alias }
171+
.distinct()
172+
.forEach { alias -> cryptoManager.deleteKey(alias) }
173+
}
174+
}
175+
176+
override fun getSupportedSecurityLevels(): Promise<SecurityAvailability> {
177+
val availability = availabilityResolver.resolve()
178+
val snapshot = SecurityAvailability(
179+
secureEnclave = availability.secureEnclave,
180+
strongBox = availability.strongBox,
181+
biometry = availability.biometry,
182+
deviceCredential = availability.deviceCredential
183+
)
184+
return Promise.resolved(snapshot)
185+
}
186+
187+
private suspend fun decryptValue(
188+
entry: PersistedEntry,
189+
metadata: StorageMetadata,
190+
prompt: AuthenticationPrompt?
191+
): String? {
192+
val ciphertext = entry.ciphertext ?: return null
193+
val iv = entry.iv ?: return null
194+
195+
val resolution = cryptoManager.buildResolutionForPersisted(
196+
accessControl = metadata.accessControl,
197+
securityLevel = metadata.securityLevel,
198+
authenticators = entry.authenticators,
199+
requiresAuth = entry.requiresAuthentication,
200+
invalidateOnEnrollment = entry.invalidateOnEnrollment,
201+
useStrongBox = entry.useStrongBox
202+
)
203+
204+
val decrypted = cryptoManager.decrypt(
205+
alias = entry.alias,
206+
ciphertext = ciphertext,
207+
iv = iv,
208+
resolution = resolution,
209+
prompt = prompt
210+
)
211+
return String(decrypted, Charsets.UTF_8)
212+
}
213+
214+
private fun fallbackMetadata(entry: PersistedEntry): StorageMetadata {
215+
val accessControl = accessControlFromPersisted(entry.metadata.accessControl) ?: AccessControl.NONE
216+
val backend = storageBackendFromPersisted(entry.metadata.backend) ?: StorageBackend.ANDROIDKEYSTORE
217+
val level = when {
218+
entry.useStrongBox -> SecurityLevel.STRONGBOX
219+
entry.requiresAuthentication -> when (accessControl) {
220+
AccessControl.DEVICEPASSCODE -> SecurityLevel.DEVICECREDENTIAL
221+
AccessControl.NONE -> SecurityLevel.SOFTWARE
222+
else -> SecurityLevel.BIOMETRY
223+
}
224+
else -> SecurityLevel.SOFTWARE
225+
}
226+
227+
return StorageMetadata(
228+
securityLevel = level,
229+
backend = backend,
230+
accessControl = accessControl,
231+
timestamp = entry.metadata.timestamp.takeIf { it > 0 } ?: nowSeconds()
232+
)
233+
}
234+
235+
private fun nowSeconds(): Double = System.currentTimeMillis() / 1000.0
236+
237+
private suspend fun maybeDeleteAlias(service: String, alias: String) {
238+
val remaining = withContext(Dispatchers.IO) { storage.readAll(service) }
239+
val stillReferenced = remaining.any { (_, entry) -> entry.alias == alias }
240+
if (!stillReferenced) {
241+
cryptoManager.deleteKey(alias)
26242
}
243+
}
27244
}

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

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
package com.sensitiveinfo;
1+
package com.sensitiveinfo
22

3-
import com.facebook.react.bridge.NativeModule;
4-
import com.facebook.react.bridge.ReactApplicationContext;
5-
import com.facebook.react.module.model.ReactModuleInfoProvider;
6-
import com.facebook.react.TurboReactPackage;
7-
import com.facebook.react.uimanager.ViewManager;
8-
import com.margelo.nitro.sensitiveinfo.*;
9-
import com.margelo.nitro.sensitiveinfo.views.*;
3+
import com.facebook.react.TurboReactPackage
4+
import com.facebook.react.bridge.NativeModule
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.module.model.ReactModuleInfoProvider
7+
import com.facebook.react.uimanager.ViewManager
8+
import com.margelo.nitro.sensitiveinfo.SensitiveInfoOnLoad
9+
import com.sensitiveinfo.internal.util.ReactContextHolder
1010

11-
12-
public class SensitiveInfoPackage : TurboReactPackage() {
13-
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
11+
class SensitiveInfoPackage : TurboReactPackage() {
12+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
13+
ReactContextHolder.install(reactContext)
14+
return null
15+
}
1416

1517
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { emptyMap() }
16-
18+
1719
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
18-
val viewManagers = ArrayList<ViewManager<*, *>>()
19-
viewManagers.add(HybridSensitiveInfoManager())
20-
return viewManagers
20+
ReactContextHolder.install(reactContext)
21+
return emptyList()
2122
}
2223

2324
companion object {

0 commit comments

Comments
 (0)