From 83dddbe4b5638387fb2d4c7c445ed8e223986e9a Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 29 Apr 2026 13:29:56 -0300 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 4 + README.md | 4 +- docs/ARCHITECTURE.md | 7 ++ example/ios/Podfile.lock | 8 +- ios/HybridSensitiveInfo.swift | 73 ++++++++++++---- src/__tests__/core.storage.test.ts | 84 +++++++++++++++++-- src/__tests__/hooks.useHasSecret.test.tsx | 22 +++++ src/__tests__/hooks.useSecretItem.test.tsx | 26 ++++++ src/__tests__/hooks.useSecureStorage.test.tsx | 15 ++++ src/__tests__/internal.options.test.ts | 33 ++++++++ src/core/storage.ts | 38 ++++++--- src/hooks/useHasSecret.ts | 4 +- src/hooks/useSecretItem.ts | 12 ++- src/hooks/useSecureStorage.ts | 12 ++- src/index.ts | 6 +- src/internal/options.ts | 34 ++++++++ src/sensitive-info.nitro.ts | 10 +-- 17 files changed, 336 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 589c7de5..358e4dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2774101a..1e201f19 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 771f4af7..6d8c38fb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 35b30d69..76ce910d 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -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 @@ -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 @@ -2102,7 +2102,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 26fd21c75314e101f280d401e97f27d54f3f7064 hermes-engine: 8d55ae9d2bb7d186d7e4b27fb3d197434d8a7d02 - NitroModules: 8146b7b58cd5a478a21485fc2a0d542076f9f1ba + NitroModules: c41b7b778d6557f1e517a80ec681a670321fa001 RCTDeprecation: c7a2768f905d76ca6d2cfefb26e4349eacbdfca3 RCTRequired: 5e502c3553cfbed090a991c444448da452fb752e RCTSwiftUI: 5ce3ccbdc58b78cc4ebbaace01709ec22d58e131 @@ -2174,7 +2174,7 @@ SPEC CHECKSUMS: ReactCodegen: 75cd4d6498ab93ae4eed4d384b78383987e7558e ReactCommon: a804bb8d1dcf3ecdec3a77eb8bba19b7863bbbdb ReactNativeDependencies: 16dfbcfc63bf756df236d05cd69638f95019c528 - SensitiveInfo: 83b9376b4a48bc310795daa351a64a5495c58b1b + SensitiveInfo: 6d078d9b88039d9316f58ae103c3c68900d22455 Yoga: 04bb4bfeb02c0000b940c1e6e89e856cd8de5a71 PODFILE CHECKSUM: 7ee3efea19ddd1156f9f61f93fc84a48ff536985 diff --git a/ios/HybridSensitiveInfo.swift b/ios/HybridSensitiveInfo.swift index 1982eada..6dd8d94d 100755 --- a/ios/HybridSensitiveInfo.swift +++ b/ios/HybridSensitiveInfo.swift @@ -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) } @@ -196,17 +200,14 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { public func hasItem(request: SensitiveInfoHasRequest) throws -> Promise { 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) } } @@ -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 [] } @@ -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) + } + 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) } @@ -388,11 +406,36 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { return result as AnyObject? case errSecItemNotFound: return nil + case errSecInteractionNotAllowed, errSecAuthFailed: + if !allowAuthentication { return nil } + 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, diff --git a/src/__tests__/core.storage.test.ts b/src/__tests__/core.storage.test.ts index aa3f6c87..e057d16c 100644 --- a/src/__tests__/core.storage.test.ts +++ b/src/__tests__/core.storage.test.ts @@ -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() @@ -42,6 +62,8 @@ describe('core/storage', () => { jest.doMock('../internal/options', () => ({ normalizeOptions, + normalizePromptedReadOptions, + normalizeStorageScopeOptions, })) jest.doMock('../internal/errors', () => ({ @@ -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() }) @@ -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 () => { @@ -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) }) @@ -186,7 +240,6 @@ describe('core/storage', () => { expect(nativeHandle.hasItem).toHaveBeenCalledWith({ key: 'token', service: 'normalized', - accessControl: 'secureEnclaveBiometry', } as SensitiveInfoHasRequest) }) @@ -201,7 +254,6 @@ describe('core/storage', () => { expect(nativeHandle.deleteItem).toHaveBeenCalledWith({ key: 'token', service: 'normalized', - accessControl: 'secureEnclaveBiometry', } as SensitiveInfoDeleteRequest) }) @@ -215,10 +267,29 @@ 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() + }) + it('clears a service via native call', async () => { const { clearService } = await loadModule() @@ -228,7 +299,6 @@ describe('core/storage', () => { expect(nativeHandle.clearService).toHaveBeenCalledWith({ service: 'normalized', - accessControl: 'secureEnclaveBiometry', }) }) @@ -284,7 +354,6 @@ describe('core/storage', () => { expect(nativeHandle.rotateKeys).toHaveBeenCalledWith({ reEncryptEagerly: false, service: 'normalized', - accessControl: 'secureEnclaveBiometry', }) }) @@ -324,7 +393,6 @@ describe('core/storage', () => { await expect(getKeyVersion({ service: 'auth' })).resolves.toBe(4) expect(nativeHandle.getKeyVersion).toHaveBeenCalledWith({ service: 'normalized', - accessControl: 'secureEnclaveBiometry', }) }) diff --git a/src/__tests__/hooks.useHasSecret.test.tsx b/src/__tests__/hooks.useHasSecret.test.tsx index e291307d..c62e223a 100644 --- a/src/__tests__/hooks.useHasSecret.test.tsx +++ b/src/__tests__/hooks.useHasSecret.test.tsx @@ -34,6 +34,28 @@ describe('useHasSecret', () => { expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' }) }) + it('keeps existence checks silent when prompt options are provided', async () => { + mockedHasItem.mockResolvedValueOnce(true) + + renderHook( + ({ opts }: { opts: Parameters[1] }) => + useHasSecret('token', opts), + { + initialProps: { + opts: { + service: 'auth', + accessControl: 'biometryCurrentSet', + authenticationPrompt: { title: 'Unlock' }, + }, + }, + } + ) + + await waitFor(() => expect(mockedHasItem).toHaveBeenCalled()) + + expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' }) + }) + it('skips querying when requested', async () => { const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => diff --git a/src/__tests__/hooks.useSecretItem.test.tsx b/src/__tests__/hooks.useSecretItem.test.tsx index a551d65e..5fc41d2a 100644 --- a/src/__tests__/hooks.useSecretItem.test.tsx +++ b/src/__tests__/hooks.useSecretItem.test.tsx @@ -38,6 +38,32 @@ describe('useSecretItem', () => { }) }) + it('keeps metadata-only reads silent when prompt options are provided', async () => { + mockedGetItem.mockResolvedValueOnce(buildTestItem()) + + renderHook( + ({ opts }: { opts: Parameters[1] }) => + useSecretItem('token', opts), + { + initialProps: { + opts: { + service: 'auth', + includeValue: false, + accessControl: 'biometryCurrentSet', + authenticationPrompt: { title: 'Unlock' }, + }, + }, + } + ) + + await waitFor(() => expect(mockedGetItem).toHaveBeenCalled()) + + expect(mockedGetItem).toHaveBeenCalledWith('token', { + service: 'auth', + includeValue: false, + }) + }) + it('skips fetching when requested', async () => { const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => diff --git a/src/__tests__/hooks.useSecureStorage.test.tsx b/src/__tests__/hooks.useSecureStorage.test.tsx index 67737009..cdd4324d 100644 --- a/src/__tests__/hooks.useSecureStorage.test.tsx +++ b/src/__tests__/hooks.useSecureStorage.test.tsx @@ -58,6 +58,21 @@ describe('useSecureStorage', () => { }) }) + it('keeps metadata listings silent when prompt options are provided', async () => { + mockedGetAllItems.mockResolvedValueOnce([]) + + renderStorage({ + service: 'auth', + includeValues: false, + accessControl: 'biometryCurrentSet', + authenticationPrompt: { title: 'Unlock' }, + }) + + await waitFor(() => expect(mockedGetAllItems).toHaveBeenCalled()) + + expect(mockedGetAllItems).toHaveBeenCalledWith({ service: 'auth' }) + }) + it('skips fetching when instructed', async () => { const { result } = renderStorage({ skip: true }) diff --git a/src/__tests__/internal.options.test.ts b/src/__tests__/internal.options.test.ts index cb246ed4..be9b8c65 100644 --- a/src/__tests__/internal.options.test.ts +++ b/src/__tests__/internal.options.test.ts @@ -2,6 +2,8 @@ import { DEFAULT_ACCESS_CONTROL, DEFAULT_SERVICE, normalizeOptions, + normalizePromptedReadOptions, + normalizeStorageScopeOptions, } from '../internal/options' describe('internal/options', () => { @@ -44,4 +46,35 @@ describe('internal/options', () => { authenticationPrompt: prompt, }) }) + + it('normalizes storage scope without access policy or prompts', () => { + expect( + normalizeStorageScopeOptions({ + service: 'custom', + accessControl: 'biometryAny', + iosSynchronizable: true, + keychainGroup: 'group.shared', + authenticationPrompt: { title: 'Authenticate' }, + }) + ).toEqual({ + service: 'custom', + iosSynchronizable: true, + keychainGroup: 'group.shared', + }) + }) + + it('normalizes prompted reads without write-only access policy', () => { + const prompt = { title: 'Authenticate' } + + expect( + normalizePromptedReadOptions({ + service: 'custom', + accessControl: 'biometryAny', + authenticationPrompt: prompt, + }) + ).toEqual({ + service: 'custom', + authenticationPrompt: prompt, + }) + }) }) diff --git a/src/core/storage.ts b/src/core/storage.ts index 07c22d1d..ba93e3cf 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -1,6 +1,10 @@ import { isNotFoundError, toSensitiveInfoError } from '../errors' import getNativeInstance from '../internal/native' -import { normalizeOptions } from '../internal/options' +import { + normalizeOptions, + normalizePromptedReadOptions, + normalizeStorageScopeOptions, +} from '../internal/options' import { validateKey, validateService, @@ -126,10 +130,13 @@ export async function getItem( validateKey(key) validateService(options) const native = getNativeInstance() + const includeValue = options?.includeValue ?? true const payload: SensitiveInfoGetRequest = { key, - includeValue: options?.includeValue ?? true, - ...normalizeOptions(options), + includeValue, + ...(includeValue + ? normalizePromptedReadOptions(options) + : normalizeStorageScopeOptions(options)), } try { @@ -144,8 +151,8 @@ export async function getItem( * Cheap existence check that never decrypts the value. * * @param key - Identifier to look up. - * @param options - Storage scoping. Avoid passing `accessControl` / `authenticationPrompt` here — - * `hasItem` is designed to be silent and should not trigger biometrics. + * @param options - Storage scoping. `accessControl` / `authenticationPrompt` are ignored here — + * `hasItem` is designed to stay silent, even for biometric-protected entries. * @returns `true` when an entry exists for the key, `false` otherwise. * * @throws {@link SensitiveInfoError} for unexpected native failures (storage IO, etc.). @@ -168,7 +175,7 @@ export async function hasItem( const native = getNativeInstance() const payload: SensitiveInfoHasRequest = { key, - ...normalizeOptions(options), + ...normalizeStorageScopeOptions(options), } try { return await native.hasItem(payload) @@ -204,7 +211,7 @@ export async function deleteItem( const native = getNativeInstance() const payload: SensitiveInfoDeleteRequest = { key, - ...normalizeOptions(options), + ...normalizeStorageScopeOptions(options), } try { return await native.deleteItem(payload) @@ -217,7 +224,8 @@ export async function deleteItem( * Enumerates every entry stored under the configured service namespace. * * @param options - Pass `{ includeValues: true }` to decrypt and return values; defaults to - * metadata-only for performance and to avoid biometric prompts on protected entries. + * metadata-only for performance and to avoid biometric prompts on protected entries. Prompt + * strings are only forwarded when values are requested. * @returns Array of {@link SensitiveInfoItem}. Returns `[]` when the service is empty. * * @throws {@link AuthenticationCanceledError} when `includeValues: true` and the user cancels. @@ -238,7 +246,9 @@ export async function getAllItems( const native = getNativeInstance() const payload: SensitiveInfoEnumerateRequest = { includeValues: options?.includeValues ?? false, - ...normalizeOptions(options), + ...(options?.includeValues === true + ? normalizePromptedReadOptions(options) + : normalizeStorageScopeOptions(options)), } try { return await native.getAllItems(payload) @@ -269,7 +279,7 @@ export async function clearService( validateService(options) const native = getNativeInstance() try { - return await native.clearService(normalizeOptions(options)) + return await native.clearService(normalizeStorageScopeOptions(options)) } catch (error) { throw toSensitiveInfoError(error) } @@ -332,7 +342,9 @@ export async function rotateKeys( const native = getNativeInstance() const payload: RotateKeysRequest = { reEncryptEagerly: options?.reEncryptEagerly ?? false, - ...normalizeOptions(options), + ...(options?.reEncryptEagerly === true + ? normalizePromptedReadOptions(options) + : normalizeStorageScopeOptions(options)), } try { return await native.rotateKeys(payload) @@ -344,7 +356,7 @@ export async function rotateKeys( /** * Returns the currently active key version for the given service. * - * @param options - Storage scoping. Avoid passing `authenticationPrompt` here — this call should + * @param options - Storage scoping. `authenticationPrompt` is ignored here — this call should * never trigger biometrics. * @returns A non-negative integer. `0` indicates a legacy entry that has not been rotated yet. * @@ -364,7 +376,7 @@ export async function getKeyVersion( validateService(options) const native = getNativeInstance() try { - return await native.getKeyVersion(normalizeOptions(options)) + return await native.getKeyVersion(normalizeStorageScopeOptions(options)) } catch (error) { throw toSensitiveInfoError(error) } diff --git a/src/hooks/useHasSecret.ts b/src/hooks/useHasSecret.ts index 7396ef9a..74998c30 100644 --- a/src/hooks/useHasSecret.ts +++ b/src/hooks/useHasSecret.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react' import { hasItem } from '../core/storage' +import { normalizeStorageScopeOptions } from '../internal/options' import type { SensitiveInfoOptions } from '../sensitive-info.nitro' import type { AsyncState } from './types' import useAsyncQuery from './useAsyncQuery' @@ -43,7 +44,8 @@ export function useHasSecret( options?: UseHasSecretOptions ): UseHasSecretResult { const runner = useCallback( - (request: SensitiveInfoOptions) => hasItem(key, request), + (request: SensitiveInfoOptions) => + hasItem(key, normalizeStorageScopeOptions(request)), [key] ) return useAsyncQuery( diff --git a/src/hooks/useSecretItem.ts b/src/hooks/useSecretItem.ts index cfc77a57..ccd086c8 100644 --- a/src/hooks/useSecretItem.ts +++ b/src/hooks/useSecretItem.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react' import { getItem } from '../core/storage' +import { normalizeStorageScopeOptions } from '../internal/options' import type { SensitiveInfoItem, SensitiveInfoOptions, @@ -57,7 +58,16 @@ export function useSecretItem( options?: UseSecretItemOptions ): UseSecretItemResult { const runner = useCallback( - (request: SensitiveInfoOptions) => getItem(key, request), + (request: SensitiveInfoOptions) => { + const includeValue = + (request as UseSecretItemOptions).includeValue ?? true + return getItem( + key, + includeValue + ? request + : { ...normalizeStorageScopeOptions(request), includeValue } + ) + }, [key] ) return useAsyncQuery( diff --git a/src/hooks/useSecureStorage.ts b/src/hooks/useSecureStorage.ts index a580586a..71a66019 100644 --- a/src/hooks/useSecureStorage.ts +++ b/src/hooks/useSecureStorage.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { clearService, deleteItem, getAllItems, setItem } from '../core/storage' +import { normalizeStorageScopeOptions } from '../internal/options' import type { SensitiveInfoItem, SensitiveInfoOptions, @@ -108,10 +109,13 @@ export function useSecureStorage( // pending mutation) during render to keep the public API stable across // option-object identity changes — a pattern the React Compiler cannot // preserve without losing the deep-equality guarantees we ship. - const fetchRunner = useCallback( - (request: SensitiveInfoOptions) => getAllItems(request), - [] - ) + const fetchRunner = useCallback((request: SensitiveInfoOptions) => { + const includeValues = + (request as UseSecureStorageOptions).includeValues === true + return getAllItems( + includeValues ? request : normalizeStorageScopeOptions(request) + ) + }, []) const fetchQuery = useAsyncQuery< SensitiveInfoItem[], diff --git a/src/index.ts b/src/index.ts index 7243e8ac..707e77fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,9 +39,9 @@ * modules. * - The default `accessControl` is **`'secureEnclaveBiometry'`** — reads on * entries written with this policy will trigger a biometric prompt. Pass - * `accessControl: 'none'` for non-sensitive caches, and **avoid sending - * `accessControl` on read paths** (enumeration, `hasItem`, `getKeyVersion`) - * to keep them silent on iOS. + * `accessControl: 'none'` for non-sensitive caches. Silent probes such as + * `hasItem`, metadata-only enumeration, and `getKeyVersion` ignore prompt + * fields on iOS; use `getItem` when the user explicitly unlocks a value. * - All errors thrown from this module are subclasses of {@link SensitiveInfoError}. * Use `instanceof` or the `is*Error` predicates to branch safely. * diff --git a/src/internal/options.ts b/src/internal/options.ts index 7860811f..5d3507e1 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1,5 +1,6 @@ import type { AccessControl, + AuthenticationPrompt, SensitiveInfoOptions, } from '../sensitive-info.nitro' @@ -39,3 +40,36 @@ export function normalizeOptions( return normalized as SensitiveInfoOptions } + +export function normalizeStorageScopeOptions( + options?: SensitiveInfoOptions +): SensitiveInfoOptions { + const normalized: Record = { + service: options?.service ?? DEFAULT_SERVICE, + } + + if (options?.iosSynchronizable !== undefined) { + normalized.iosSynchronizable = options.iosSynchronizable + } + if (options?.keychainGroup !== undefined) { + normalized.keychainGroup = options.keychainGroup + } + + return normalized as SensitiveInfoOptions +} + +export function normalizePromptedReadOptions( + options?: SensitiveInfoOptions +): SensitiveInfoOptions { + const normalized = normalizeStorageScopeOptions(options) as Record< + string, + unknown + > + + if (options?.authenticationPrompt !== undefined) { + normalized.authenticationPrompt = + options.authenticationPrompt as AuthenticationPrompt + } + + return normalized as SensitiveInfoOptions +} diff --git a/src/sensitive-info.nitro.ts b/src/sensitive-info.nitro.ts index 728e6af6..cc0870e6 100644 --- a/src/sensitive-info.nitro.ts +++ b/src/sensitive-info.nitro.ts @@ -82,9 +82,9 @@ export interface AuthenticationPrompt { /** * Tunables shared by both the read and write APIs. * - * Pass the same `service` on read and write to scope your secrets to a logical namespace, and - * mirror the `accessControl`/`authenticationPrompt` you used on write so the platform can satisfy - * the access policy without surprise prompts. + * Pass the same `service` on read and write to scope your secrets to a logical namespace. + * `accessControl` is a write policy; silent reads such as `hasItem`, metadata-only enumeration, + * and `getKeyVersion` intentionally ignore prompt-bearing fields on iOS. * * @see {@link AccessControl} * @see {@link AuthenticationPrompt} @@ -110,8 +110,8 @@ export interface SensitiveInfoOptions { */ readonly accessControl?: AccessControl /** - * Prompt strings displayed when user presence is required to open the key. Omit on read paths - * (enumeration, `hasItem`, `getKeyVersion`) to keep them silent on iOS. + * Prompt strings displayed when user presence is required to open the key. Use this for + * value reads/writes, not silent probes such as `hasItem` or metadata-only enumeration. */ readonly authenticationPrompt?: AuthenticationPrompt } From 595829faccd620745430d367b8b18789f9c87bc1 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Wed, 29 Apr 2026 13:48:16 -0300 Subject: [PATCH 2/2] Forward hasItem options; change iOS auth errors Stop normalizing storage options in useHasSecret and pass the provided SensitiveInfoOptions straight to hasItem. Update unit tests to assert the forwarded options and the native getAllItems call signature. On iOS, remove the early-return that suppressed errSecInteractionNotAllowed/errSecAuthFailed when allowAuthentication was false so those statuses now raise the runtime error instead of returning nil. --- ios/HybridSensitiveInfo.swift | 1 - src/__tests__/core.storage.test.ts | 4 ++++ src/__tests__/hooks.useHasSecret.test.tsx | 17 ++++++++--------- src/hooks/useHasSecret.ts | 4 +--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ios/HybridSensitiveInfo.swift b/ios/HybridSensitiveInfo.swift index 6dd8d94d..7888e9af 100755 --- a/ios/HybridSensitiveInfo.swift +++ b/ios/HybridSensitiveInfo.swift @@ -407,7 +407,6 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { case errSecItemNotFound: return nil case errSecInteractionNotAllowed, errSecAuthFailed: - if !allowAuthentication { return nil } throw runtimeError(for: status, operation: "fetch") default: throw runtimeError(for: status, operation: "fetch") diff --git a/src/__tests__/core.storage.test.ts b/src/__tests__/core.storage.test.ts index e057d16c..586318a2 100644 --- a/src/__tests__/core.storage.test.ts +++ b/src/__tests__/core.storage.test.ts @@ -288,6 +288,10 @@ describe('core/storage', () => { authenticationPrompt: prompt, }) expect(normalizePromptedReadOptions).not.toHaveBeenCalled() + expect(nativeHandle.getAllItems).toHaveBeenCalledWith({ + includeValues: false, + service: 'normalized', + } as SensitiveInfoEnumerateRequest) }) it('clears a service via native call', async () => { diff --git a/src/__tests__/hooks.useHasSecret.test.tsx b/src/__tests__/hooks.useHasSecret.test.tsx index c62e223a..339f6772 100644 --- a/src/__tests__/hooks.useHasSecret.test.tsx +++ b/src/__tests__/hooks.useHasSecret.test.tsx @@ -34,26 +34,25 @@ describe('useHasSecret', () => { expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' }) }) - it('keeps existence checks silent when prompt options are provided', async () => { + 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[1] }) => useHasSecret('token', opts), { - initialProps: { - opts: { - service: 'auth', - accessControl: 'biometryCurrentSet', - authenticationPrompt: { title: 'Unlock' }, - }, - }, + initialProps: { opts: options }, } ) await waitFor(() => expect(mockedHasItem).toHaveBeenCalled()) - expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' }) + expect(mockedHasItem).toHaveBeenCalledWith('token', options) }) it('skips querying when requested', async () => { diff --git a/src/hooks/useHasSecret.ts b/src/hooks/useHasSecret.ts index 74998c30..7396ef9a 100644 --- a/src/hooks/useHasSecret.ts +++ b/src/hooks/useHasSecret.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react' import { hasItem } from '../core/storage' -import { normalizeStorageScopeOptions } from '../internal/options' import type { SensitiveInfoOptions } from '../sensitive-info.nitro' import type { AsyncState } from './types' import useAsyncQuery from './useAsyncQuery' @@ -44,8 +43,7 @@ export function useHasSecret( options?: UseHasSecretOptions ): UseHasSecretResult { const runner = useCallback( - (request: SensitiveInfoOptions) => - hasItem(key, normalizeStorageScopeOptions(request)), + (request: SensitiveInfoOptions) => hasItem(key, request), [key] ) return useAsyncQuery(