Skip to content

Commit 08a56d6

Browse files
committed
Remove androidBiometricsStrongOnly option and enforce strong biometrics automatically
The androidBiometricsStrongOnly option has been removed from the API and implementation. Android now automatically enforces Class 3 (strong) biometrics when supported by hardware, falling back to the strongest available authenticator on older devices. Documentation and type definitions have been updated to reflect this change, and related UI and code paths have been cleaned up.
1 parent 283f790 commit 08a56d6

7 files changed

Lines changed: 47 additions & 47 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ All functions live at the top level export and return Promises.
159159
- `authenticationPrompt` — localized strings for biometric/device credential prompts.
160160
- `iosSynchronizable` — enable iCloud Keychain sync.
161161
- `keychainGroup` — custom Keychain access group.
162-
- `androidBiometricsStrongOnly` — require Class 3 biometrics when generating keys.
162+
163+
Android automatically enforces Class 3 biometrics whenever the hardware supports them, falling back to the strongest available authenticator on older devices.
163164

164165
See `src/views/sensitive-info.nitro.ts` for full TypeScript definitions.
165166

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
7777
override fun setItem(request: SensitiveInfoSetRequest): Promise<MutationResult> {
7878
return Promise.async {
7979
val service = resolveService(request.service)
80-
val strongOnly = request.androidBiometricsStrongOnly == true
81-
val resolution = accessControlResolver.resolve(request.accessControl, strongOnly)
80+
val resolution = accessControlResolver.resolve(request.accessControl)
8281
val alias = AliasGenerator.create(service, resolution.signature)
8382

8483
val previousEntry = readEntry(service, request.key)

android/src/main/java/com/sensitiveinfo/internal/crypto/AccessControlResolver.kt

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.sensitiveinfo.internal.crypto
22

3+
import android.os.Build
34
import androidx.biometric.BiometricManager.Authenticators
45
import com.margelo.nitro.sensitiveinfo.AccessControl
56
import com.margelo.nitro.sensitiveinfo.SecurityLevel
@@ -23,12 +24,12 @@ internal class AccessControlResolver(
2324
)
2425

2526
/** Chooses the best available policy given the caller preference and hardware capabilities. */
26-
fun resolve(preferred: AccessControl?, strongBiometricsOnly: Boolean): AccessResolution {
27+
fun resolve(preferred: AccessControl?): AccessResolution {
2728
val availability = availabilityResolver.resolve()
2829
val ordered = orderPreferences(preferred)
2930

3031
for (candidate in ordered) {
31-
val resolution = tryResolve(candidate, availability, strongBiometricsOnly)
32+
val resolution = tryResolve(candidate, availability)
3233
if (resolution != null) {
3334
return resolution
3435
}
@@ -57,12 +58,11 @@ internal class AccessControlResolver(
5758

5859
private fun tryResolve(
5960
accessControl: AccessControl,
60-
availability: SecurityAvailabilitySnapshot,
61-
strongBiometricsOnly: Boolean
61+
availability: SecurityAvailabilitySnapshot
6262
): AccessResolution? {
6363
return when (accessControl) {
6464
AccessControl.SECUREENCLAVEBIOMETRY -> {
65-
if (!availability.biometry || !availability.strongBox) return null
65+
if (!availability.biometry || !availability.strongBiometrics || !availability.strongBox) return null
6666
AccessResolution(
6767
accessControl = AccessControl.SECUREENCLAVEBIOMETRY,
6868
securityLevel = SecurityLevel.STRONGBOX,
@@ -73,31 +73,23 @@ internal class AccessControlResolver(
7373
)
7474
}
7575
AccessControl.BIOMETRYCURRENTSET -> {
76-
if (!availability.biometry) return null
76+
val allowedAuthenticators = allowedBiometricAuthenticators(availability) ?: return null
7777
AccessResolution(
7878
accessControl = AccessControl.BIOMETRYCURRENTSET,
7979
securityLevel = SecurityLevel.BIOMETRY,
8080
requiresAuthentication = true,
81-
allowedAuthenticators = if (strongBiometricsOnly) {
82-
Authenticators.BIOMETRIC_STRONG
83-
} else {
84-
Authenticators.BIOMETRIC_STRONG or Authenticators.BIOMETRIC_WEAK
85-
},
81+
allowedAuthenticators = allowedAuthenticators,
8682
useStrongBox = false,
8783
invalidateOnEnrollment = true
8884
)
8985
}
9086
AccessControl.BIOMETRYANY -> {
91-
if (!availability.biometry) return null
87+
val allowedAuthenticators = allowedBiometricAuthenticators(availability) ?: return null
9288
AccessResolution(
9389
accessControl = AccessControl.BIOMETRYANY,
9490
securityLevel = SecurityLevel.BIOMETRY,
9591
requiresAuthentication = true,
96-
allowedAuthenticators = if (strongBiometricsOnly) {
97-
Authenticators.BIOMETRIC_STRONG
98-
} else {
99-
Authenticators.BIOMETRIC_STRONG or Authenticators.BIOMETRIC_WEAK
100-
},
92+
allowedAuthenticators = allowedAuthenticators,
10193
useStrongBox = false,
10294
invalidateOnEnrollment = false
10395
)
@@ -123,4 +115,20 @@ internal class AccessControlResolver(
123115
)
124116
}
125117
}
118+
119+
private fun allowedBiometricAuthenticators(availability: SecurityAvailabilitySnapshot): Int? {
120+
if (!availability.biometry) {
121+
return null
122+
}
123+
124+
if (availability.strongBiometrics) {
125+
return Authenticators.BIOMETRIC_STRONG
126+
}
127+
128+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
129+
Authenticators.BIOMETRIC_WEAK
130+
} else {
131+
Authenticators.BIOMETRIC_STRONG
132+
}
133+
}
126134
}

android/src/main/java/com/sensitiveinfo/internal/crypto/SecurityAvailabilityResolver.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ internal data class SecurityAvailabilitySnapshot(
1313
val secureEnclave: Boolean,
1414
val strongBox: Boolean,
1515
val biometry: Boolean,
16+
val strongBiometrics: Boolean,
1617
val deviceCredential: Boolean
1718
)
1819

@@ -35,10 +36,9 @@ internal class SecurityAvailabilityResolver(private val context: Context) {
3536
}
3637

3738
val biometricManager = BiometricManager.from(context)
38-
val hasBiometry = when (biometricManager.canAuthenticate(Authenticators.BIOMETRIC_STRONG)) {
39-
BiometricManager.BIOMETRIC_SUCCESS -> true
40-
else -> biometricManager.canAuthenticate(Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
41-
}
39+
val hasStrongBiometrics = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
40+
val hasWeakBiometrics = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
41+
val hasBiometry = hasStrongBiometrics || hasWeakBiometrics
4242

4343
val keyguard = context.getSystemService<KeyguardManager>()
4444
val deviceCredential = keyguard?.isDeviceSecure == true
@@ -50,6 +50,7 @@ internal class SecurityAvailabilityResolver(private val context: Context) {
5050
secureEnclave = hasStrongBox,
5151
strongBox = hasStrongBox,
5252
biometry = hasBiometry,
53+
strongBiometrics = hasStrongBiometrics,
5354
deviceCredential = deviceCredential
5455
)
5556
cached = snapshot

example/App.tsx

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,11 @@ function ToggleRow({ label, helper, value, onValueChange }: ToggleRowProps) {
176176
onValueChange={onValueChange}
177177
trackColor={{ false: '#d1d5db', true: '#bfdbfe' }}
178178
thumbColor={
179-
Platform.OS === 'android' ? (value ? '#2563eb' : '#f9fafb') : undefined
179+
Platform.OS === 'android'
180+
? value
181+
? '#2563eb'
182+
: '#f9fafb'
183+
: undefined
180184
}
181185
ios_backgroundColor="#d1d5db"
182186
/>
@@ -193,8 +197,6 @@ function App(): React.JSX.Element {
193197
const [includeValues, setIncludeValues] = useState(true);
194198
const [includeValueOnGet, setIncludeValueOnGet] = useState(true);
195199
const [iosSynchronizable, setIosSynchronizable] = useState(false);
196-
const [androidBiometricsStrongOnly, setAndroidBiometricsStrongOnly] =
197-
useState(false);
198200
const [usePrompt, setUsePrompt] = useState(true);
199201
const [keychainGroup, setKeychainGroup] = useState('');
200202
const [availability, setAvailability] = useState<SecurityAvailability | null>(
@@ -222,9 +224,6 @@ function App(): React.JSX.Element {
222224
accessControl: selectedAccessControl,
223225
iosSynchronizable: iosSynchronizable ? true : undefined,
224226
keychainGroup: normalizedKeychainGroup,
225-
androidBiometricsStrongOnly: androidBiometricsStrongOnly
226-
? true
227-
: undefined,
228227
authenticationPrompt: usePrompt
229228
? {
230229
title: 'Authenticate to continue',
@@ -236,7 +235,6 @@ function App(): React.JSX.Element {
236235
: undefined,
237236
}),
238237
[
239-
androidBiometricsStrongOnly,
240238
iosSynchronizable,
241239
normalizedKeychainGroup,
242240
normalizedService,
@@ -401,9 +399,9 @@ function App(): React.JSX.Element {
401399
<View style={styles.banner}>
402400
<Text style={styles.bannerTitle}>Tip for hardware testing</Text>
403401
<Text style={styles.bannerText}>
404-
Simulators rarely expose Secure Enclave, StrongBox, or full biometric
405-
flows. Validate critical journeys on a physical device to mirror
406-
production behaviour.
402+
Simulators rarely expose Secure Enclave, StrongBox, or full
403+
biometric flows. Validate critical journeys on a physical device to
404+
mirror production behaviour.
407405
</Text>
408406
</View>
409407

@@ -507,7 +505,7 @@ function App(): React.JSX.Element {
507505
subtitle="Tune access control, prompts, and cross-platform behaviour"
508506
>
509507
<View style={styles.accessOptionsContainer}>
510-
{ACCESS_CONTROL_OPTIONS.map((option) => {
508+
{ACCESS_CONTROL_OPTIONS.map(option => {
511509
const selected = option.value === selectedAccessControl;
512510
return (
513511
<Pressable
@@ -561,12 +559,6 @@ function App(): React.JSX.Element {
561559
value={iosSynchronizable}
562560
onValueChange={setIosSynchronizable}
563561
/>
564-
<ToggleRow
565-
label="Require strong biometrics (Android)"
566-
helper="Limits authentication to Class 3 credentials where available."
567-
value={androidBiometricsStrongOnly}
568-
onValueChange={setAndroidBiometricsStrongOnly}
569-
/>
570562

571563
<Field
572564
label="Shared keychain access group"
@@ -631,7 +623,7 @@ function App(): React.JSX.Element {
631623
Nothing stored yet. Save a secret to see it appear here.
632624
</Text>
633625
) : (
634-
items.map((item) => (
626+
items.map(item => (
635627
<View key={`${item.service}-${item.key}`} style={styles.itemCard}>
636628
<Text style={styles.itemTitle}>{item.key}</Text>
637629
<Text style={styles.itemMeta}>Service · {item.service}</Text>
@@ -649,7 +641,8 @@ function App(): React.JSX.Element {
649641
Backend · {item.metadata.backend}
650642
</Text>
651643
<Text style={styles.itemRow}>
652-
Stored at · {new Date(item.metadata.timestamp * 1000).toLocaleString()}
644+
Stored at ·{' '}
645+
{new Date(item.metadata.timestamp * 1000).toLocaleString()}
653646
</Text>
654647
</View>
655648
</View>

src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ function resolveOptions(options?: SensitiveInfoOptions): SensitiveInfoOptions {
4747
accessControl: options.accessControl ?? DEFAULT_ACCESS_CONTROL,
4848
iosSynchronizable: options.iosSynchronizable,
4949
keychainGroup: options.keychainGroup,
50-
androidBiometricsStrongOnly: options.androidBiometricsStrongOnly,
5150
authenticationPrompt: options.authenticationPrompt,
5251
}
5352
}

src/views/sensitive-info.nitro.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ export interface AuthenticationPrompt {
5656
*
5757
* `iosSynchronizable`, `keychainGroup`, and the access-control options apply to every Apple
5858
* platform (iOS, macOS, visionOS, watchOS) even if the field name still mentions iOS for
59-
* backwards compatibility.
59+
* backwards compatibility. On Android, strong (Class 3) biometrics are enforced automatically
60+
* whenever the hardware supports them, gracefully falling back to the strongest available guard.
6061
*/
6162
export interface SensitiveInfoOptions {
6263
/** Namespaces the stored entry. Defaults to the bundle identifier (when available) or `default`. */
@@ -70,8 +71,6 @@ export interface SensitiveInfoOptions {
7071
* strongest supported strategy (Secure Enclave ➝ Biometry ➝ Device Credential ➝ None).
7172
*/
7273
readonly accessControl?: AccessControl
73-
/** Android: opt-in to strict hardware-backed biometrics (skips weak face unlock for example). */
74-
readonly androidBiometricsStrongOnly?: boolean
7574
/** Optional prompt strings displayed when user presence is required to open the key. */
7675
readonly authenticationPrompt?: AuthenticationPrompt
7776
}

0 commit comments

Comments
 (0)