Skip to content

Commit 00ce292

Browse files
authored
Merge pull request #601 from mCodex/fix/doublePromptiOS
Prevent duplicate iOS biometric prompts & silent reads
2 parents e131859 + 595829f commit 00ce292

16 files changed

Lines changed: 335 additions & 55 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: 57 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,35 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
388406
return result as AnyObject?
389407
case errSecItemNotFound:
390408
return nil
409+
case errSecInteractionNotAllowed, errSecAuthFailed:
410+
throw runtimeError(for: status, operation: "fetch")
391411
default:
392412
throw runtimeError(for: status, operation: "fetch")
393413
}
394414
}
395415

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

src/__tests__/core.storage.test.ts

Lines changed: 80 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,7 +267,30 @@ describe('core/storage', () => {
215267
expect(nativeHandle.getAllItems).toHaveBeenCalledWith({
216268
includeValues: true,
217269
service: 'normalized',
218-
accessControl: 'secureEnclaveBiometry',
270+
} as SensitiveInfoEnumerateRequest)
271+
})
272+
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+
expect(nativeHandle.getAllItems).toHaveBeenCalledWith({
292+
includeValues: false,
293+
service: 'normalized',
219294
} as SensitiveInfoEnumerateRequest)
220295
})
221296

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

229304
expect(nativeHandle.clearService).toHaveBeenCalledWith({
230305
service: 'normalized',
231-
accessControl: 'secureEnclaveBiometry',
232306
})
233307
})
234308

@@ -284,7 +358,6 @@ describe('core/storage', () => {
284358
expect(nativeHandle.rotateKeys).toHaveBeenCalledWith({
285359
reEncryptEagerly: false,
286360
service: 'normalized',
287-
accessControl: 'secureEnclaveBiometry',
288361
})
289362
})
290363

@@ -324,7 +397,6 @@ describe('core/storage', () => {
324397
await expect(getKeyVersion({ service: 'auth' })).resolves.toBe(4)
325398
expect(nativeHandle.getKeyVersion).toHaveBeenCalledWith({
326399
service: 'normalized',
327-
accessControl: 'secureEnclaveBiometry',
328400
})
329401
})
330402

src/__tests__/hooks.useHasSecret.test.tsx

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

37+
it('forwards existence options to the silent hasItem boundary', async () => {
38+
mockedHasItem.mockResolvedValueOnce(true)
39+
const options = {
40+
service: 'auth',
41+
accessControl: 'biometryCurrentSet' as const,
42+
authenticationPrompt: { title: 'Unlock' },
43+
}
44+
45+
renderHook(
46+
({ opts }: { opts: Parameters<typeof useHasSecret>[1] }) =>
47+
useHasSecret('token', opts),
48+
{
49+
initialProps: { opts: options },
50+
}
51+
)
52+
53+
await waitFor(() => expect(mockedHasItem).toHaveBeenCalled())
54+
55+
expect(mockedHasItem).toHaveBeenCalledWith('token', options)
56+
})
57+
3758
it('skips querying when requested', async () => {
3859
const { result } = renderHook(
3960
({ opts }: { opts: Parameters<typeof useHasSecret>[1] }) =>

0 commit comments

Comments
 (0)