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..7888e9af 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,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, diff --git a/src/__tests__/core.storage.test.ts b/src/__tests__/core.storage.test.ts index aa3f6c87..586318a2 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,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) }) @@ -228,7 +303,6 @@ describe('core/storage', () => { expect(nativeHandle.clearService).toHaveBeenCalledWith({ service: 'normalized', - accessControl: 'secureEnclaveBiometry', }) }) @@ -284,7 +358,6 @@ describe('core/storage', () => { expect(nativeHandle.rotateKeys).toHaveBeenCalledWith({ reEncryptEagerly: false, service: 'normalized', - accessControl: 'secureEnclaveBiometry', }) }) @@ -324,7 +397,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..339f6772 100644 --- a/src/__tests__/hooks.useHasSecret.test.tsx +++ b/src/__tests__/hooks.useHasSecret.test.tsx @@ -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[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[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/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 }