Skip to content

Prevent duplicate iOS biometric prompts & silent reads#601

Merged
mCodex merged 2 commits intomasterfrom
fix/doublePromptiOS
Apr 29, 2026
Merged

Prevent duplicate iOS biometric prompts & silent reads#601
mCodex merged 2 commits intomasterfrom
fix/doublePromptiOS

Conversation

@mCodex
Copy link
Copy Markdown
Owner

@mCodex mCodex commented Apr 29, 2026

This pull request addresses a bug on iOS where duplicate biometric prompts could appear during existence checks and metadata-only reads, and improves the handling of authentication prompts across the codebase. The changes ensure that silent operations such as existence checks and metadata-only enumerations no longer trigger biometric prompts, while value reads and writes handle prompts correctly. The update also introduces new normalization utilities and expands test coverage to verify the correct prompt behavior.

iOS biometric prompt handling improvements:

  • Refactored the iOS native layer (HybridSensitiveInfo.swift) to ensure that existence checks (hasItem) and metadata-only operations do not trigger biometric prompts, by introducing an allowAuthentication flag and a dedicated itemExists method. Value reads now only prompt once per operation. [1] [2] [3] [4]
  • Updated documentation (docs/ARCHITECTURE.md, README.md) to clarify prompt boundaries and option semantics for silent vs. prompted operations. [1] [2]
  • Added a changelog entry describing the fix for duplicate biometric prompts on iOS.

Core logic and API changes:

  • Updated getItem in src/core/storage.ts to use new normalization functions: normalizePromptedReadOptions for value reads (with prompt), and normalizeStorageScopeOptions for silent metadata-only reads. [1] [2]
  • Added normalizePromptedReadOptions and normalizeStorageScopeOptions to the internal options module and expanded their test coverage. [1] [2]

Test coverage improvements:

  • Expanded tests in src/__tests__/core.storage.test.ts and hooks tests to verify that silent operations do not forward prompt options and do not trigger biometric prompts, even when prompt options are provided. [1] [2] [3] [4] [5] [6] [7] [8] [9]

These changes collectively ensure a smoother and more predictable biometric prompt experience on iOS, aligning the library's behavior with platform expectations and improving developer ergonomics.

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.
Copilot AI review requested due to automatic review settings April 29, 2026 16:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes iOS Keychain prompt behavior so “silent” operations (existence checks and metadata-only reads/enumerations) don’t trigger biometric UI, while value reads/writes continue to prompt appropriately.

Changes:

  • Split option normalization into “storage scope only” vs “prompted read” paths, and applied them across core APIs + hooks to avoid forwarding prompt-bearing fields for silent operations.
  • Refactored iOS native Keychain querying to control authentication UI (including adding a dedicated silent existence-check path).
  • Expanded unit tests to verify prompt options are not forwarded for silent operations.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/sensitive-info.nitro.ts Updates TS docs to clarify silent vs prompted behavior (esp. iOS prompt boundaries).
src/internal/options.ts Adds normalizeStorageScopeOptions and normalizePromptedReadOptions helpers.
src/index.ts Updates package-level docs around iOS prompt behavior.
src/hooks/useSecureStorage.ts Ensures metadata-only listings strip prompt-bearing fields before calling getAllItems.
src/hooks/useSecretItem.ts Ensures metadata-only single-item reads strip prompt-bearing fields before calling getItem.
src/hooks/useHasSecret.ts Ensures existence checks strip prompt-bearing fields before calling hasItem.
src/core/storage.ts Routes each operation through the appropriate normalization path (silent vs prompted).
src/tests/internal.options.test.ts Adds coverage for new normalization helpers.
src/tests/hooks.useSecureStorage.test.tsx Tests silent enumeration doesn’t forward prompt options.
src/tests/hooks.useSecretItem.test.tsx Tests silent metadata-only reads don’t forward prompt options.
src/tests/hooks.useHasSecret.test.tsx Tests silent existence checks don’t forward prompt options.
src/tests/core.storage.test.ts Expands tests to assert correct normalizer selection per operation.
ios/HybridSensitiveInfo.swift Refactors Keychain query behavior to prevent duplicate prompts and support silent paths.
example/ios/Podfile.lock Updates example app pod versions.
docs/ARCHITECTURE.md Documents iOS prompt boundaries and intended silent-path semantics.
README.md Clarifies option semantics and prompt forwarding behavior.
CHANGELOG.md Adds release note for iOS duplicate prompt fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +387 to +391
if !allowAuthentication {
workingQuery[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
} else if let prompt {
workingQuery[kSecUseOperationPrompt as String] = prompt.title
workingQuery[kSecUseAuthenticationContext as String] = makeLAContext(prompt: prompt)
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.
Comment thread docs/ARCHITECTURE.md
Comment on lines +72 to +73
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.
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.
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.
@mCodex mCodex merged commit 00ce292 into master Apr 29, 2026
6 of 7 checks passed
@mCodex mCodex deleted the fix/doublePromptiOS branch April 29, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants