Skip to content

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

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

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

Conversation

@grndcherokee
Copy link

@grndcherokee grndcherokee commented Feb 3, 2026

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 AUTHENTICATE_WITH_CRYPTO_OBJECT constant
    • Add getAuthenticateWithCryptoObject() helper
    • Pass option through decrypt/encrypt call chain
  • ResultHandlerProvider.kt: Accept and pass authenticateWithCryptoObject parameter
  • ResultHandlerInteractiveBiometric.kt:
    • Accept authenticateWithCryptoObject constructor parameter
    • Conditionally use CryptoObject in authenticateWithPrompt()
    • 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.

Migration Notes

For apps experiencing CryptoFailedException with "User not authenticated" on Samsung devices:

// Add the option to your keychain calls
await Keychain.getGenericPassword({
  authenticateWithCryptoObject: true,
});

Note: When enabling this option, Face Unlock may stop working on some devices. Only enable if you're experiencing the race condition issue and fingerprint authentication is acceptable for your use case.

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
This commit adds patches previously maintained separately by Klarna:

1. Skip StrongBox on slow OEM implementations (Motorola, Xiaomi)
   - Add SLOW_STRONGBOX_MANUFACTURERS set
   - Add isSlowStrongBoxManufacturer() function
   - Skip StrongBox key generation for these manufacturers

2. Enhanced error reporting with stack traces
   - Add generateUserInfo() helper to attach stack traces
   - Include stack traces in all promise.reject calls
   - Helps with debugging keychain errors in production

These changes are additive and don't affect the CryptoObject fix.
When installing from GitHub (not npm), the prepare script doesn't run
automatically, causing TypeScript type definitions to be missing.

This commit includes the pre-built lib folder to ensure the package
works correctly when installed directly from GitHub.
@tiagomelilo
Copy link

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.

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
Copy link
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!


var secretKey: Key? = null
val supportsSecureHardware = DeviceAvailability.isStrongboxAvailable(applicationContext)
val slowStrongBox = DeviceAvailability.isSlowStrongBoxManufacturer()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we need this and how do we know they are slow?

// Prepare cipher for CryptoObject binding to prevent timing issues
// between biometric authentication and crypto operations.
val cipher = try {
val c = getCachedInstance()
Copy link
Collaborator

@DorianMazur DorianMazur Feb 24, 2026

Choose a reason for hiding this comment

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

Why in try catch? I don't think this function can throw.

@@ -0,0 +1,115 @@
"use strict";
Copy link
Collaborator

@DorianMazur DorianMazur Feb 24, 2026

Choose a reason for hiding this comment

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

Please remove the lib files, clean up this PR.

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.

4 participants