Skip to content

fix(android): Use CryptoObject to prevent UserNotAuthenticatedException - #788

Open
grndcherokee wants to merge 4 commits into
oblador:masterfrom
grndcherokee:fix/crypto-object-binding
Open

fix(android): Use CryptoObject to prevent UserNotAuthenticatedException#788
grndcherokee wants to merge 4 commits into
oblador:masterfrom
grndcherokee:fix/crypto-object-binding

Conversation

@grndcherokee

@grndcherokee grndcherokee commented Feb 3, 2026

Copy link
Copy Markdown

Summary

Fix race condition between BiometricPrompt.onAuthenticationSucceeded and subsequent crypto operations that causes CryptoFailedException: User not authenticated on some Android devices (particularly Samsung S24/S25).

Problem

When setUserAuthenticationRequired(true) is set on a KeyStore key with a validity duration (e.g., 5 seconds), there's a timing window between:

  1. BiometricPrompt.onAuthenticationSucceeded callback firing
  2. The actual crypto operation (cipher.doFinal()) executing

On some devices, particularly Samsung Galaxy S24/S25 with fingerprint authentication, this window can expire before the crypto operation runs, causing UserNotAuthenticatedException wrapped in CryptoFailedException.

The root cause is that the current implementation calls prompt.authenticate(promptInfo) without a CryptoObject, then attempts crypto operations in the success callback. This decouples the authentication from the crypto operation, creating a race condition.

Solution

Use BiometricPrompt.CryptoObject to atomically bind the biometric authentication to the crypto operation, controlled via a new opt-in option:

  1. Pre-initialize the Cipher before calling BiometricPrompt.authenticate()
  2. Pass it as BiometricPrompt.CryptoObject(cipher) to authenticate()
  3. Use the authenticated cipher from AuthenticationResult.cryptoObject in onAuthenticationSucceeded

This ensures the crypto operation is cryptographically bound to the biometric authentication, eliminating timing-related failures.

New Option: authenticateWithCryptoObject

A new optional parameter has been added to SetOptions and GetOptions:

await Keychain.setGenericPassword('username', 'password', {
  accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
  authenticateWithCryptoObject: true, // Enable CryptoObject binding
});

await Keychain.getGenericPassword({
  authenticateWithCryptoObject: true, // Enable CryptoObject binding
});

When enabled (true):

  • Biometric authentication is atomically bound to the crypto operation
  • Prevents race conditions causing "User not authenticated" errors
  • Only Class 3 (Strong) biometrics are allowed — typically fingerprint
  • Face Unlock may not work on devices where it's classified as Class 2 (Weak)

When disabled (false, default):

  • Standard authentication flow (current behavior)
  • All biometric types supported including Face Unlock
  • Maintains full backward compatibility

Changes

  • types.ts: Add authenticateWithCryptoObject?: boolean to SetOptions and GetOptions
  • KeychainModule.kt: Add constant, helper, and pass option through decrypt/encrypt call chain
  • ResultHandlerProvider.kt: Accept and pass authenticateWithCryptoObject parameter
  • ResultHandlerInteractiveBiometric.kt: Conditionally use CryptoObject in authenticateWithPrompt() and use authenticated cipher from AuthenticationResult.cryptoObject in success callback
  • ResultHandlerInteractiveBiometricManualRetry.kt: Pass through the parameter
  • ResultHandler.kt: Add optional cipher field to CryptoContext
  • CipherStorageKeystoreRsaEcb.kt: Initialize cipher before biometric prompt
  • CipherStorageKeystoreAesGcm.kt: Initialize cipher before biometric prompt

Backwards Compatibility

  • Fully backward compatible — default is false, preserving existing behavior
  • No changes to key generation or storage format
  • Existing credentials remain valid and accessible
  • No changes required for existing applications
  • Apps can opt-in to stronger security when needed

Testing

  • Covered by existing E2E tests in accessControlTest.spec.js which test biometric save/load flows
  • Falls back gracefully if cipher initialization fails
  • Tested manually on Samsung S24/S25 devices where the issue was reported

Related Issues

Fixes timing-related UserNotAuthenticatedException errors reported on Samsung devices.

Fix race condition between BiometricPrompt.onAuthenticationSucceeded
and subsequent crypto operations that causes "User not authenticated"
errors on some Android devices (particularly Samsung S24/S25).

When setUserAuthenticationRequired(true) is set on a KeyStore key with
a validity duration, there's a timing window between when the callback
fires and when the crypto operation executes. On some devices, this
window can expire before the crypto runs.

The fix uses BiometricPrompt.CryptoObject to atomically bind the
biometric authentication to the crypto operation:

1. Pre-initialize Cipher before calling BiometricPrompt.authenticate()
2. Pass it as BiometricPrompt.CryptoObject(cipher)
3. Use authenticated cipher from AuthenticationResult.cryptoObject

This ensures the crypto operation is cryptographically bound to the
biometric authentication, eliminating timing-related failures.

Changes:
- ResultHandler.kt: Add optional cipher field to CryptoContext
- CipherStorageKeystoreRsaEcb.kt: Init cipher for decrypt
- CipherStorageKeystoreAesGcm.kt: Init cipher for encrypt/decrypt
- ResultHandlerInteractiveBiometric.kt: Use CryptoObject for auth
@tiagomelilo

Copy link
Copy Markdown

Hi @grndcherokee. I'm using your version on my project. On my package.json i'm using "react-native-keychain": "git+https://github.com/grndcherokee/react-native-keychain.git#fix/crypto-object-binding", instead of "react-native-keychain": "10.0.0".

But, now, on my android device I'm only able to log in using fingerprint biometrics. The error message "authenticate to retrieve secret" appear and only fingerprint biometrics is acessible for log in.

@grndcherokee

Copy link
Copy Markdown
Author

Hi! Thanks for trying out the fork and reporting this issue.

This is actually expected behavior when using CryptoObject binding - it enforces Class 3 (Strong) biometrics only, which typically means fingerprint. Face Unlock on most Android devices is classified as Class 2 (Weak) biometrics and won't work when CryptoObject is used.

Good news: I've just pushed an update that makes this behavior configurable via a new authenticateWithCryptoObject option.

To restore Face Unlock support, you can now explicitly set it to false (or simply omit it, as false is the default):

await Keychain.getGenericPassword({
  authenticateWithCryptoObject: false, // Allow all biometric types including Face Unlock
});

If you want the stronger security that prevents the "User not authenticated" race condition (but limits to fingerprint), use:

await Keychain.getGenericPassword({
  authenticateWithCryptoObject: true, // Class 3 biometrics only (fingerprint)
});

Please update to the latest commit on the fix/crypto-object-binding branch and let me know if this resolves your issue!

Comment thread android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt Outdated
Comment thread lib/commonjs/enums.js Outdated
Add `authenticateWithCryptoObject` option to SetOptions and GetOptions
to allow developers to opt-in to CryptoObject-bound biometric
authentication.

When enabled:
- Biometric authentication is atomically bound to the crypto operation
- Prevents race conditions causing "User not authenticated" errors
- Only Class 3 (Strong) biometrics allowed (typically fingerprint)
- Face Unlock may not work on devices with Class 2 classification

When disabled (default):
- Standard authentication flow is used
- All biometric types supported including Face Unlock
- Maintains backward compatibility with existing implementations

This makes the security enhancement opt-in to avoid breaking changes for
users who rely on Face Unlock or other Class 2 biometrics.

Co-authored-by: Cursor <cursoragent@cursor.com>
@grndcherokee
grndcherokee force-pushed the fix/crypto-object-binding branch from fd4cacb to 7246541 Compare March 3, 2026 14:40
@grndcherokee

Copy link
Copy Markdown
Author

Hi @DorianMazur! I addressed the feedback from the previous review and pushed the requested changes.
Just checking if everything looks good now, or if there’s anything else I should update. Thanks!

DorianMazur and others added 2 commits March 23, 2026 21:18
… enabled

Skip the cipher pre-initialization in UserNotAuthenticatedException catch
blocks when authenticateWithCryptoObject is false (the default). The extra
cipher.init() calls add latency before the biometric prompt appears, causing
test-android (35) to fail after the turbo module merge enabled new arch.

Made-with: Cursor
@grndcherokee

Copy link
Copy Markdown
Author

Pushed a fix for the API 35 test failure (de25b97).

Root cause: The cipher pre-initialization in the UserNotAuthenticatedException catch blocks (added for CryptoObject binding) runs unconditionally — even when authenticateWithCryptoObject is false (the default). Each attempt calls cipher.init() on the auth-protected key, which throws again, gets caught, and logs a warning. This extra work adds latency before the biometric prompt appears. On the API 35 emulator, this pushes the prompt past the 1-second window the test waits before sending the ADB fingerprint command, causing timeouts.

Fix: Added a needsCryptoObjectBinding property to the ResultHandler interface so the cipher storage can check whether pre-initialization is actually needed. When authenticateWithCryptoObject is false (the default), the cipher pre-init is skipped entirely and cipher is set to null immediately — making the default path identical in timing to master.

Could someone approve the workflow run so CI can verify?

@grndcherokee

Copy link
Copy Markdown
Author

Hi @DorianMazur 👋 Just a gentle nudge — when you get a chance, could you finish your review on this PR? I've addressed all the previous feedback and the latest fix (de25b97) resolves the API 35 test failure. Happy to make any further changes if needed. Thanks!

@grndcherokee

Copy link
Copy Markdown
Author

Hey @oblador / @DorianMazur / @vonovak / @pcoltau 👋

Just a gentle nudge - Would you be OK if we merge this?

@grndcherokee

Copy link
Copy Markdown
Author

Hey @DorianMazur — just following up on this one. I know you've been busy with the turbo module work and other PRs, so I appreciate your time.

Quick summary of where this stands:

All 7 CI checks are passing
All previous review feedback has been addressed
The authenticateWithCryptoObject option is opt-in (false by default), so it's fully backward-compatible
This fixes a real UserNotAuthenticatedException race condition on Android that several people are hitting. Happy to jump on a quick call or make any further changes if that helps move this forward.

@grndcherokee

Copy link
Copy Markdown
Author

Hey @oblador / @DorianMazur / @vonovak / @pcoltau 👋

Just a gentle nudge - Would you be OK if we merge this?

1 similar comment
@grndcherokee

Copy link
Copy Markdown
Author

Hey @oblador / @DorianMazur / @vonovak / @pcoltau 👋

Just a gentle nudge - Would you be OK if we merge this?

@Mina-George137

Copy link
Copy Markdown

Hey @oblador / @DorianMazur / @vonovak / @pcoltau 👋

Just a gentle nudge - Would you be OK if we merge this?

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.

5 participants