Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

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

### Bug Fixes

* **ios:** prevent duplicate biometric prompts on existence checks and prompted value reads

## [6.1.2](https://github.com/mCodex/react-native-sensitive-info/compare/v6.1.1...v6.1.2) (2026-04-29)

### 🛠️ Other changes
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,8 +413,8 @@ All functions live at the top level export and return Promises.
### 🧩 Options shared by all operations

- `service` (default: bundle identifier or `default`) — logical namespace for secrets.
- `accessControl` (default: `secureEnclaveBiometry`) — preferred policy; the native layer chooses the strongest supported fallback.
- `authenticationPrompt` — localized strings for biometric/device credential prompts.
- `accessControl` (default on writes: `secureEnclaveBiometry`) — preferred write policy; the native layer chooses the strongest supported fallback.
- `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`.
- `iosSynchronizable` — enable iCloud Keychain sync.
- `keychainGroup` — custom Keychain access group.

Expand Down
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,10 @@ or eagerly walks the existing entries when `reEncryptEagerly: true`.
The package sets `"sideEffects": false` and ships ESM via subpath exports. Hooks live behind
`react-native-sensitive-info/hooks` so apps that only use the imperative API never pay for the
hook bundle. Errors are also re-exported from `/errors` for the same reason.

## iOS prompt boundaries

On iOS, Keychain queries against biometric-protected entries can authenticate even when callers
only ask for attributes. The native layer keeps `hasItem` and metadata-only enumeration on a
dedicated silent path, while value reads own an `LAContext` from the first `SecItemCopyMatching`
attempt so one user action maps to one Face ID / Touch ID sheet.
Comment on lines +72 to +73
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section suggests metadata-only enumeration has a “dedicated silent path” that still behaves like a normal listing. With the current native implementation, metadata-only queries disable auth UI and can end up omitting/returning nothing for biometric-protected entries (Keychain may respond with errSecInteractionNotAllowed). Consider clarifying here what callers should expect (e.g., protected items may not be listable without includeValues: true, and hasItem is the reliable silent existence probe).

Suggested change
dedicated silent path, while value reads own an `LAContext` from the first `SecItemCopyMatching`
attempt so one user action maps to one Face ID / Touch ID sheet.
silent path that disables auth UI, while value reads own an `LAContext` from the first
`SecItemCopyMatching` attempt so one user action maps to one Face ID / Touch ID sheet.
Callers should not assume metadata-only enumeration behaves like a normal listing for
biometric-protected entries: Keychain may return `errSecInteractionNotAllowed`, so protected
items can be omitted or the result can be empty unless the query allows a value read
(for example, `includeValues: true`). For a silent existence check, prefer `hasItem`.

Copilot uses AI. Check for mistakes.
8 changes: 4 additions & 4 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ PODS:
- hermes-engine (250829098.0.10):
- hermes-engine/Pre-built (= 250829098.0.10)
- hermes-engine/Pre-built (250829098.0.10)
- NitroModules (0.35.5):
- NitroModules (0.35.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -1839,7 +1839,7 @@ PODS:
- React-utils (= 0.85.2)
- ReactNativeDependencies
- ReactNativeDependencies (0.85.2)
- SensitiveInfo (6.0.0):
- SensitiveInfo (6.1.3):
- hermes-engine
- NitroModules
- RCTRequired
Expand Down Expand Up @@ -2102,7 +2102,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
FBLazyVector: 26fd21c75314e101f280d401e97f27d54f3f7064
hermes-engine: 8d55ae9d2bb7d186d7e4b27fb3d197434d8a7d02
NitroModules: 8146b7b58cd5a478a21485fc2a0d542076f9f1ba
NitroModules: c41b7b778d6557f1e517a80ec681a670321fa001
RCTDeprecation: c7a2768f905d76ca6d2cfefb26e4349eacbdfca3
RCTRequired: 5e502c3553cfbed090a991c444448da452fb752e
RCTSwiftUI: 5ce3ccbdc58b78cc4ebbaace01709ec22d58e131
Expand Down Expand Up @@ -2174,7 +2174,7 @@ SPEC CHECKSUMS:
ReactCodegen: 75cd4d6498ab93ae4eed4d384b78383987e7558e
ReactCommon: a804bb8d1dcf3ecdec3a77eb8bba19b7863bbbdb
ReactNativeDependencies: 16dfbcfc63bf756df236d05cd69638f95019c528
SensitiveInfo: 83b9376b4a48bc310795daa351a64a5495c58b1b
SensitiveInfo: 6d078d9b88039d9316f58ae103c3c68900d22455
Yoga: 04bb4bfeb02c0000b940c1e6e89e856cd8de5a71

PODFILE CHECKSUM: 7ee3efea19ddd1156f9f61f93fc84a48ff536985
Expand Down
72 changes: 57 additions & 15 deletions ios/HybridSensitiveInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,11 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
query[kSecReturnData as String] = kCFBooleanTrue
}

guard let raw = try copyMatching(query: query, prompt: request.authenticationPrompt) as? NSDictionary else {
guard let raw = try copyMatching(
query: query,
prompt: includeValue ? request.authenticationPrompt : nil,
allowAuthentication: includeValue
) as? NSDictionary else {
return Variant_NullType_SensitiveInfoItem.first(NullType.null)
}

Expand Down Expand Up @@ -196,17 +200,14 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
public func hasItem(request: SensitiveInfoHasRequest) throws -> Promise<Bool> {
Promise.parallel(workQueue) { [self] in
let service = normalizedService(request.service)
var query = makeBaseQuery(
let query = makeBaseQuery(
key: request.key,
service: service,
synchronizable: request.iosSynchronizable,
accessGroup: request.keychainGroup
)
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnAttributes as String] = kCFBooleanTrue

let result = try copyMatching(query: query, prompt: request.authenticationPrompt)
return result != nil
return try itemExists(query: query)
}
}

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

let result = try copyMatching(query: query, prompt: request?.authenticationPrompt)
let result = try copyMatching(
query: query,
prompt: includeValues ? request?.authenticationPrompt : nil,
allowAuthentication: includeValues
)
guard let array = result as? [NSDictionary] else {
return []
}
Expand Down Expand Up @@ -368,18 +373,31 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
SecItemDelete(deleteQuery as CFDictionary)
}

private func copyMatching(query: [String: Any], prompt: AuthenticationPrompt?) throws -> AnyObject? {
private func copyMatching(
query: [String: Any],
prompt: AuthenticationPrompt?,
allowAuthentication: Bool = true
) throws -> AnyObject? {
#if targetEnvironment(simulator)
try performSimulatorBiometricPromptIfNeeded(prompt: prompt)
if allowAuthentication {
try performSimulatorBiometricPromptIfNeeded(prompt: prompt)
}
#endif
var workingQuery = query
if !allowAuthentication {
workingQuery[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
} else if let prompt {
workingQuery[kSecUseOperationPrompt as String] = prompt.title
workingQuery[kSecUseAuthenticationContext as String] = makeLAContext(prompt: prompt)
Comment on lines +387 to +391
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When allowAuthentication is false, this sets kSecUseAuthenticationUIFail. For SecItemCopyMatching queries that request attributes (e.g. metadata-only getItem / getAllItems), Keychain commonly returns errSecInteractionNotAllowed for biometric-protected items, and the current code maps that to nil (so JS sees null/[] even though entries exist). To keep silent operations usable, consider using kSecUseAuthenticationUISkip for enumeration (so non-protected items are still returned without failing), and for single-item metadata reads treat errSecInteractionNotAllowed/errSecAuthFailed as “exists but locked” (e.g. consult itemExists and return a minimal/fallback item instead of not-found).

Copilot uses AI. Check for mistakes.
}

var result: CFTypeRef?
var status = performCopyMatching(query as CFDictionary, result: &result)
var status = performCopyMatching(workingQuery as CFDictionary, result: &result)

if status == errSecInteractionNotAllowed || status == errSecAuthFailed {
var authQuery = query
authQuery[kSecUseOperationPrompt as String] = prompt?.title ?? "Authenticate to access sensitive data"
let context = makeLAContext(prompt: prompt)
authQuery[kSecUseAuthenticationContext as String] = context
if allowAuthentication && prompt == nil && (status == errSecInteractionNotAllowed || status == errSecAuthFailed) {
var authQuery = workingQuery
authQuery[kSecUseOperationPrompt as String] = "Authenticate to access sensitive data"
authQuery[kSecUseAuthenticationContext as String] = makeLAContext(prompt: nil)
status = performCopyMatching(authQuery as CFDictionary, result: &result)
}

Expand All @@ -388,11 +406,35 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec {
return result as AnyObject?
case errSecItemNotFound:
return nil
case errSecInteractionNotAllowed, errSecAuthFailed:
throw runtimeError(for: status, operation: "fetch")
default:
throw runtimeError(for: status, operation: "fetch")
}
}

private func itemExists(query: [String: Any]) throws -> Bool {
var existenceQuery = query
existenceQuery[kSecMatchLimit as String] = kSecMatchLimitOne
existenceQuery[kSecReturnData as String] = kCFBooleanFalse
existenceQuery[kSecReturnAttributes as String] = kCFBooleanFalse
existenceQuery[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail

var result: CFTypeRef?
let status = performCopyMatching(existenceQuery as CFDictionary, result: &result)

switch status {
case errSecSuccess:
return true
case errSecItemNotFound:
return false
case errSecInteractionNotAllowed, errSecAuthFailed:
return true
default:
throw runtimeError(for: status, operation: "existence check")
}
}

private func makeItem(from dictionary: NSDictionary, includeValue: Bool) throws -> SensitiveInfoItem {
guard
let key = dictionary[kSecAttrAccount as String] as? String,
Expand Down
88 changes: 80 additions & 8 deletions src/__tests__/core.storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ describe('core/storage', () => {
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
})
const normalizePromptedReadOptions = jest
.fn<
ReturnType<
typeof import('../internal/options').normalizePromptedReadOptions
>,
[SensitiveInfoOptions | undefined]
>()
.mockReturnValue({
service: 'normalized',
})
const normalizeStorageScopeOptions = jest
.fn<
ReturnType<
typeof import('../internal/options').normalizeStorageScopeOptions
>,
[SensitiveInfoOptions | undefined]
>()
.mockReturnValue({
service: 'normalized',
})

const isNotFoundError = jest.fn()

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

jest.doMock('../internal/options', () => ({
normalizeOptions,
normalizePromptedReadOptions,
normalizeStorageScopeOptions,
}))

jest.doMock('../internal/errors', () => ({
Expand All @@ -63,6 +85,14 @@ describe('core/storage', () => {
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
})
normalizePromptedReadOptions.mockClear()
normalizePromptedReadOptions.mockReturnValue({
service: 'normalized',
})
normalizeStorageScopeOptions.mockClear()
normalizeStorageScopeOptions.mockReturnValue({
service: 'normalized',
})
isNotFoundError.mockReset()
})

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

expect(result).toBeNull()
expect(normalizeOptions).toHaveBeenCalled()
expect(normalizePromptedReadOptions).toHaveBeenCalled()
})

it('rethrows unexpected errors during getItem', async () => {
Expand All @@ -116,7 +146,31 @@ describe('core/storage', () => {
key: 'token',
includeValue: true,
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
} as SensitiveInfoGetRequest)
})

it('keeps metadata-only getItem calls silent', async () => {
const { getItem } = await loadModule()

nativeHandle.getItem.mockResolvedValueOnce({ key: 'token' })
const prompt = { title: 'Authenticate' }

await getItem('token', {
service: 'service',
includeValue: false,
authenticationPrompt: prompt,
})

expect(normalizeStorageScopeOptions).toHaveBeenCalledWith({
service: 'service',
includeValue: false,
authenticationPrompt: prompt,
})
expect(normalizePromptedReadOptions).not.toHaveBeenCalled()
expect(nativeHandle.getItem).toHaveBeenCalledWith({
key: 'token',
includeValue: false,
service: 'normalized',
} as SensitiveInfoGetRequest)
})

Expand Down Expand Up @@ -186,7 +240,6 @@ describe('core/storage', () => {
expect(nativeHandle.hasItem).toHaveBeenCalledWith({
key: 'token',
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
} as SensitiveInfoHasRequest)
})

Expand All @@ -201,7 +254,6 @@ describe('core/storage', () => {
expect(nativeHandle.deleteItem).toHaveBeenCalledWith({
key: 'token',
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
} as SensitiveInfoDeleteRequest)
})

Expand All @@ -215,7 +267,30 @@ describe('core/storage', () => {
expect(nativeHandle.getAllItems).toHaveBeenCalledWith({
includeValues: true,
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
} as SensitiveInfoEnumerateRequest)
})

it('keeps metadata-only enumeration silent', async () => {
const { getAllItems } = await loadModule()

nativeHandle.getAllItems.mockResolvedValueOnce([])
const prompt = { title: 'Authenticate' }

await getAllItems({
service: 'service',
includeValues: false,
authenticationPrompt: prompt,
})

expect(normalizeStorageScopeOptions).toHaveBeenCalledWith({
service: 'service',
includeValues: false,
authenticationPrompt: prompt,
})
expect(normalizePromptedReadOptions).not.toHaveBeenCalled()
expect(nativeHandle.getAllItems).toHaveBeenCalledWith({
includeValues: false,
service: 'normalized',
} as SensitiveInfoEnumerateRequest)
})

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

expect(nativeHandle.clearService).toHaveBeenCalledWith({
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
})
})

Expand Down Expand Up @@ -284,7 +358,6 @@ describe('core/storage', () => {
expect(nativeHandle.rotateKeys).toHaveBeenCalledWith({
reEncryptEagerly: false,
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
})
})

Expand Down Expand Up @@ -324,7 +397,6 @@ describe('core/storage', () => {
await expect(getKeyVersion({ service: 'auth' })).resolves.toBe(4)
expect(nativeHandle.getKeyVersion).toHaveBeenCalledWith({
service: 'normalized',
accessControl: 'secureEnclaveBiometry',
})
})

Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/hooks.useHasSecret.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,27 @@ describe('useHasSecret', () => {
expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' })
})

it('forwards existence options to the silent hasItem boundary', async () => {
mockedHasItem.mockResolvedValueOnce(true)
const options = {
service: 'auth',
accessControl: 'biometryCurrentSet' as const,
authenticationPrompt: { title: 'Unlock' },
}

renderHook(
({ opts }: { opts: Parameters<typeof useHasSecret>[1] }) =>
useHasSecret('token', opts),
{
initialProps: { opts: options },
}
)

await waitFor(() => expect(mockedHasItem).toHaveBeenCalled())

expect(mockedHasItem).toHaveBeenCalledWith('token', options)
})

it('skips querying when requested', async () => {
const { result } = renderHook(
({ opts }: { opts: Parameters<typeof useHasSecret>[1] }) =>
Expand Down
Loading
Loading