Skip to content

Commit 83dddbe

Browse files
committed
Prevent duplicate iOS biometric prompts & silent reads
Avoid double Face ID / Touch ID prompts and keep metadata-only operations silent on iOS. Native Swift changes add an allowAuthentication flag, an itemExists fast-path, and set kSecUseAuthenticationUIFail for non-auth probes so hasItem and metadata enumeration never trigger authentication. JS API separates option normalization into storage-scope vs prompted-read helpers (normalizeStorageScopeOptions, normalizePromptedReadOptions) and updates core storage functions and hooks to only forward prompts when values are explicitly requested. Tests, docs, README and example iOS lockfile updated to reflect behavior and API clarifications.
1 parent e131859 commit 83dddbe

17 files changed

Lines changed: 336 additions & 56 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [6.1.3](https://github.com/mCodex/react-native-sensitive-info/compare/v6.1.2...v6.1.3) (2026-04-29)
44

5+
### Bug Fixes
6+
7+
* **ios:** prevent duplicate biometric prompts on existence checks and prompted value reads
8+
59
## [6.1.2](https://github.com/mCodex/react-native-sensitive-info/compare/v6.1.1...v6.1.2) (2026-04-29)
610

711
### 🛠️ Other changes

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,8 +413,8 @@ All functions live at the top level export and return Promises.
413413
### 🧩 Options shared by all operations
414414

415415
- `service` (default: bundle identifier or `default`) — logical namespace for secrets.
416-
- `accessControl` (default: `secureEnclaveBiometry`) — preferred policy; the native layer chooses the strongest supported fallback.
417-
- `authenticationPrompt` — localized strings for biometric/device credential prompts.
416+
- `accessControl` (default on writes: `secureEnclaveBiometry`) — preferred write policy; the native layer chooses the strongest supported fallback.
417+
- `authenticationPrompt` — localized strings for biometric/device credential prompts. Forwarded for value reads/writes, ignored by silent probes such as `hasItem`, `getKeyVersion`, and metadata-only `getAllItems`.
418418
- `iosSynchronizable` — enable iCloud Keychain sync.
419419
- `keychainGroup` — custom Keychain access group.
420420

docs/ARCHITECTURE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,10 @@ or eagerly walks the existing entries when `reEncryptEagerly: true`.
6464
The package sets `"sideEffects": false` and ships ESM via subpath exports. Hooks live behind
6565
`react-native-sensitive-info/hooks` so apps that only use the imperative API never pay for the
6666
hook bundle. Errors are also re-exported from `/errors` for the same reason.
67+
68+
## iOS prompt boundaries
69+
70+
On iOS, Keychain queries against biometric-protected entries can authenticate even when callers
71+
only ask for attributes. The native layer keeps `hasItem` and metadata-only enumeration on a
72+
dedicated silent path, while value reads own an `LAContext` from the first `SecItemCopyMatching`
73+
attempt so one user action maps to one Face ID / Touch ID sheet.

example/ios/Podfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ PODS:
33
- hermes-engine (250829098.0.10):
44
- hermes-engine/Pre-built (= 250829098.0.10)
55
- hermes-engine/Pre-built (250829098.0.10)
6-
- NitroModules (0.35.5):
6+
- NitroModules (0.35.6):
77
- hermes-engine
88
- RCTRequired
99
- RCTTypeSafety
@@ -1839,7 +1839,7 @@ PODS:
18391839
- React-utils (= 0.85.2)
18401840
- ReactNativeDependencies
18411841
- ReactNativeDependencies (0.85.2)
1842-
- SensitiveInfo (6.0.0):
1842+
- SensitiveInfo (6.1.3):
18431843
- hermes-engine
18441844
- NitroModules
18451845
- RCTRequired
@@ -2102,7 +2102,7 @@ EXTERNAL SOURCES:
21022102
SPEC CHECKSUMS:
21032103
FBLazyVector: 26fd21c75314e101f280d401e97f27d54f3f7064
21042104
hermes-engine: 8d55ae9d2bb7d186d7e4b27fb3d197434d8a7d02
2105-
NitroModules: 8146b7b58cd5a478a21485fc2a0d542076f9f1ba
2105+
NitroModules: c41b7b778d6557f1e517a80ec681a670321fa001
21062106
RCTDeprecation: c7a2768f905d76ca6d2cfefb26e4349eacbdfca3
21072107
RCTRequired: 5e502c3553cfbed090a991c444448da452fb752e
21082108
RCTSwiftUI: 5ce3ccbdc58b78cc4ebbaace01709ec22d58e131
@@ -2174,7 +2174,7 @@ SPEC CHECKSUMS:
21742174
ReactCodegen: 75cd4d6498ab93ae4eed4d384b78383987e7558e
21752175
ReactCommon: a804bb8d1dcf3ecdec3a77eb8bba19b7863bbbdb
21762176
ReactNativeDependencies: 16dfbcfc63bf756df236d05cd69638f95019c528
2177-
SensitiveInfo: 83b9376b4a48bc310795daa351a64a5495c58b1b
2177+
SensitiveInfo: 6d078d9b88039d9316f58ae103c3c68900d22455
21782178
Yoga: 04bb4bfeb02c0000b940c1e6e89e856cd8de5a71
21792179

21802180
PODFILE CHECKSUM: 7ee3efea19ddd1156f9f61f93fc84a48ff536985

ios/HybridSensitiveInfo.swift

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,11 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
157157
query[kSecReturnData as String] = kCFBooleanTrue
158158
}
159159

160-
guard let raw = try copyMatching(query: query, prompt: request.authenticationPrompt) as? NSDictionary else {
160+
guard let raw = try copyMatching(
161+
query: query,
162+
prompt: includeValue ? request.authenticationPrompt : nil,
163+
allowAuthentication: includeValue
164+
) as? NSDictionary else {
161165
return Variant_NullType_SensitiveInfoItem.first(NullType.null)
162166
}
163167

@@ -196,17 +200,14 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
196200
public func hasItem(request: SensitiveInfoHasRequest) throws -> Promise<Bool> {
197201
Promise.parallel(workQueue) { [self] in
198202
let service = normalizedService(request.service)
199-
var query = makeBaseQuery(
203+
let query = makeBaseQuery(
200204
key: request.key,
201205
service: service,
202206
synchronizable: request.iosSynchronizable,
203207
accessGroup: request.keychainGroup
204208
)
205-
query[kSecMatchLimit as String] = kSecMatchLimitOne
206-
query[kSecReturnAttributes as String] = kCFBooleanTrue
207209

208-
let result = try copyMatching(query: query, prompt: request.authenticationPrompt)
209-
return result != nil
210+
return try itemExists(query: query)
210211
}
211212
}
212213

@@ -232,7 +233,11 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
232233
query[kSecReturnData as String] = kCFBooleanTrue
233234
}
234235

235-
let result = try copyMatching(query: query, prompt: request?.authenticationPrompt)
236+
let result = try copyMatching(
237+
query: query,
238+
prompt: includeValues ? request?.authenticationPrompt : nil,
239+
allowAuthentication: includeValues
240+
)
236241
guard let array = result as? [NSDictionary] else {
237242
return []
238243
}
@@ -368,18 +373,31 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
368373
SecItemDelete(deleteQuery as CFDictionary)
369374
}
370375

371-
private func copyMatching(query: [String: Any], prompt: AuthenticationPrompt?) throws -> AnyObject? {
376+
private func copyMatching(
377+
query: [String: Any],
378+
prompt: AuthenticationPrompt?,
379+
allowAuthentication: Bool = true
380+
) throws -> AnyObject? {
372381
#if targetEnvironment(simulator)
373-
try performSimulatorBiometricPromptIfNeeded(prompt: prompt)
382+
if allowAuthentication {
383+
try performSimulatorBiometricPromptIfNeeded(prompt: prompt)
384+
}
374385
#endif
386+
var workingQuery = query
387+
if !allowAuthentication {
388+
workingQuery[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
389+
} else if let prompt {
390+
workingQuery[kSecUseOperationPrompt as String] = prompt.title
391+
workingQuery[kSecUseAuthenticationContext as String] = makeLAContext(prompt: prompt)
392+
}
393+
375394
var result: CFTypeRef?
376-
var status = performCopyMatching(query as CFDictionary, result: &result)
395+
var status = performCopyMatching(workingQuery as CFDictionary, result: &result)
377396

378-
if status == errSecInteractionNotAllowed || status == errSecAuthFailed {
379-
var authQuery = query
380-
authQuery[kSecUseOperationPrompt as String] = prompt?.title ?? "Authenticate to access sensitive data"
381-
let context = makeLAContext(prompt: prompt)
382-
authQuery[kSecUseAuthenticationContext as String] = context
397+
if allowAuthentication && prompt == nil && (status == errSecInteractionNotAllowed || status == errSecAuthFailed) {
398+
var authQuery = workingQuery
399+
authQuery[kSecUseOperationPrompt as String] = "Authenticate to access sensitive data"
400+
authQuery[kSecUseAuthenticationContext as String] = makeLAContext(prompt: nil)
383401
status = performCopyMatching(authQuery as CFDictionary, result: &result)
384402
}
385403

@@ -388,11 +406,36 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
388406
return result as AnyObject?
389407
case errSecItemNotFound:
390408
return nil
409+
case errSecInteractionNotAllowed, errSecAuthFailed:
410+
if !allowAuthentication { return nil }
411+
throw runtimeError(for: status, operation: "fetch")
391412
default:
392413
throw runtimeError(for: status, operation: "fetch")
393414
}
394415
}
395416

417+
private func itemExists(query: [String: Any]) throws -> Bool {
418+
var existenceQuery = query
419+
existenceQuery[kSecMatchLimit as String] = kSecMatchLimitOne
420+
existenceQuery[kSecReturnData as String] = kCFBooleanFalse
421+
existenceQuery[kSecReturnAttributes as String] = kCFBooleanFalse
422+
existenceQuery[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
423+
424+
var result: CFTypeRef?
425+
let status = performCopyMatching(existenceQuery as CFDictionary, result: &result)
426+
427+
switch status {
428+
case errSecSuccess:
429+
return true
430+
case errSecItemNotFound:
431+
return false
432+
case errSecInteractionNotAllowed, errSecAuthFailed:
433+
return true
434+
default:
435+
throw runtimeError(for: status, operation: "existence check")
436+
}
437+
}
438+
396439
private func makeItem(from dictionary: NSDictionary, includeValue: Bool) throws -> SensitiveInfoItem {
397440
guard
398441
let key = dictionary[kSecAttrAccount as String] as? String,

src/__tests__/core.storage.test.ts

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,26 @@ describe('core/storage', () => {
2929
service: 'normalized',
3030
accessControl: 'secureEnclaveBiometry',
3131
})
32+
const normalizePromptedReadOptions = jest
33+
.fn<
34+
ReturnType<
35+
typeof import('../internal/options').normalizePromptedReadOptions
36+
>,
37+
[SensitiveInfoOptions | undefined]
38+
>()
39+
.mockReturnValue({
40+
service: 'normalized',
41+
})
42+
const normalizeStorageScopeOptions = jest
43+
.fn<
44+
ReturnType<
45+
typeof import('../internal/options').normalizeStorageScopeOptions
46+
>,
47+
[SensitiveInfoOptions | undefined]
48+
>()
49+
.mockReturnValue({
50+
service: 'normalized',
51+
})
3252

3353
const isNotFoundError = jest.fn()
3454

@@ -42,6 +62,8 @@ describe('core/storage', () => {
4262

4363
jest.doMock('../internal/options', () => ({
4464
normalizeOptions,
65+
normalizePromptedReadOptions,
66+
normalizeStorageScopeOptions,
4567
}))
4668

4769
jest.doMock('../internal/errors', () => ({
@@ -63,6 +85,14 @@ describe('core/storage', () => {
6385
service: 'normalized',
6486
accessControl: 'secureEnclaveBiometry',
6587
})
88+
normalizePromptedReadOptions.mockClear()
89+
normalizePromptedReadOptions.mockReturnValue({
90+
service: 'normalized',
91+
})
92+
normalizeStorageScopeOptions.mockClear()
93+
normalizeStorageScopeOptions.mockReturnValue({
94+
service: 'normalized',
95+
})
6696
isNotFoundError.mockReset()
6797
})
6898

@@ -92,7 +122,7 @@ describe('core/storage', () => {
92122
const result = await getItem('token', { service: 'service' })
93123

94124
expect(result).toBeNull()
95-
expect(normalizeOptions).toHaveBeenCalled()
125+
expect(normalizePromptedReadOptions).toHaveBeenCalled()
96126
})
97127

98128
it('rethrows unexpected errors during getItem', async () => {
@@ -116,7 +146,31 @@ describe('core/storage', () => {
116146
key: 'token',
117147
includeValue: true,
118148
service: 'normalized',
119-
accessControl: 'secureEnclaveBiometry',
149+
} as SensitiveInfoGetRequest)
150+
})
151+
152+
it('keeps metadata-only getItem calls silent', async () => {
153+
const { getItem } = await loadModule()
154+
155+
nativeHandle.getItem.mockResolvedValueOnce({ key: 'token' })
156+
const prompt = { title: 'Authenticate' }
157+
158+
await getItem('token', {
159+
service: 'service',
160+
includeValue: false,
161+
authenticationPrompt: prompt,
162+
})
163+
164+
expect(normalizeStorageScopeOptions).toHaveBeenCalledWith({
165+
service: 'service',
166+
includeValue: false,
167+
authenticationPrompt: prompt,
168+
})
169+
expect(normalizePromptedReadOptions).not.toHaveBeenCalled()
170+
expect(nativeHandle.getItem).toHaveBeenCalledWith({
171+
key: 'token',
172+
includeValue: false,
173+
service: 'normalized',
120174
} as SensitiveInfoGetRequest)
121175
})
122176

@@ -186,7 +240,6 @@ describe('core/storage', () => {
186240
expect(nativeHandle.hasItem).toHaveBeenCalledWith({
187241
key: 'token',
188242
service: 'normalized',
189-
accessControl: 'secureEnclaveBiometry',
190243
} as SensitiveInfoHasRequest)
191244
})
192245

@@ -201,7 +254,6 @@ describe('core/storage', () => {
201254
expect(nativeHandle.deleteItem).toHaveBeenCalledWith({
202255
key: 'token',
203256
service: 'normalized',
204-
accessControl: 'secureEnclaveBiometry',
205257
} as SensitiveInfoDeleteRequest)
206258
})
207259

@@ -215,10 +267,29 @@ describe('core/storage', () => {
215267
expect(nativeHandle.getAllItems).toHaveBeenCalledWith({
216268
includeValues: true,
217269
service: 'normalized',
218-
accessControl: 'secureEnclaveBiometry',
219270
} as SensitiveInfoEnumerateRequest)
220271
})
221272

273+
it('keeps metadata-only enumeration silent', async () => {
274+
const { getAllItems } = await loadModule()
275+
276+
nativeHandle.getAllItems.mockResolvedValueOnce([])
277+
const prompt = { title: 'Authenticate' }
278+
279+
await getAllItems({
280+
service: 'service',
281+
includeValues: false,
282+
authenticationPrompt: prompt,
283+
})
284+
285+
expect(normalizeStorageScopeOptions).toHaveBeenCalledWith({
286+
service: 'service',
287+
includeValues: false,
288+
authenticationPrompt: prompt,
289+
})
290+
expect(normalizePromptedReadOptions).not.toHaveBeenCalled()
291+
})
292+
222293
it('clears a service via native call', async () => {
223294
const { clearService } = await loadModule()
224295

@@ -228,7 +299,6 @@ describe('core/storage', () => {
228299

229300
expect(nativeHandle.clearService).toHaveBeenCalledWith({
230301
service: 'normalized',
231-
accessControl: 'secureEnclaveBiometry',
232302
})
233303
})
234304

@@ -284,7 +354,6 @@ describe('core/storage', () => {
284354
expect(nativeHandle.rotateKeys).toHaveBeenCalledWith({
285355
reEncryptEagerly: false,
286356
service: 'normalized',
287-
accessControl: 'secureEnclaveBiometry',
288357
})
289358
})
290359

@@ -324,7 +393,6 @@ describe('core/storage', () => {
324393
await expect(getKeyVersion({ service: 'auth' })).resolves.toBe(4)
325394
expect(nativeHandle.getKeyVersion).toHaveBeenCalledWith({
326395
service: 'normalized',
327-
accessControl: 'secureEnclaveBiometry',
328396
})
329397
})
330398

src/__tests__/hooks.useHasSecret.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ describe('useHasSecret', () => {
3434
expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' })
3535
})
3636

37+
it('keeps existence checks silent when prompt options are provided', async () => {
38+
mockedHasItem.mockResolvedValueOnce(true)
39+
40+
renderHook(
41+
({ opts }: { opts: Parameters<typeof useHasSecret>[1] }) =>
42+
useHasSecret('token', opts),
43+
{
44+
initialProps: {
45+
opts: {
46+
service: 'auth',
47+
accessControl: 'biometryCurrentSet',
48+
authenticationPrompt: { title: 'Unlock' },
49+
},
50+
},
51+
}
52+
)
53+
54+
await waitFor(() => expect(mockedHasItem).toHaveBeenCalled())
55+
56+
expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' })
57+
})
58+
3759
it('skips querying when requested', async () => {
3860
const { result } = renderHook(
3961
({ opts }: { opts: Parameters<typeof useHasSecret>[1] }) =>

0 commit comments

Comments
 (0)