Skip to content

fix: keep AndroidKeyStore access off the main thread during initialize - #52

Open
vargajacint wants to merge 2 commits into
GleapSDK:masterfrom
vargajacint:fix/keystore-off-main-thread
Open

vargajacint wants to merge 2 commits into
GleapSDK:masterfrom
vargajacint:fix/keystore-off-main-thread

Conversation

@vargajacint

Copy link
Copy Markdown

Problem

Gleap.initialize can stall the main thread on AndroidKeyStore binder calls and cause ANRs shortly after launch. This is most visible when the host calls initialize on the UI thread (the React Native bridge does, inside runOnUiThread) on low-end devices (Android 13-15).

Root cause

GleapSessionController.initialize constructs the controller synchronously, and the construction does all the keystore work on the calling thread:

  • GleapSessionController(Application) calls GleapPreferencesHelper.getInstance (GleapSessionController.java:30), whose constructor calls keystoreCipher() -> getOrCreateKey() (GleapPreferencesHelper.java:102, :179, :190). On first launch this is KeyGenerator.generateKey(), a keystore2 generateKey binder call into the TEE.
  • The controller constructor then reads the stored session and user (GleapSessionController.java:37-44): about 14 getString calls, each a ValueCipher.decrypt (GleapPreferencesHelper.java:66) that does a keystore2 createOperation binder call.

Each call is a blocking IPC into the secure hardware, so the total can exceed the ANR threshold on slow devices.

Fix

  • GleapPreferencesHelper: resolve the keystore cipher lazily on first read or write instead of in the constructor, so getInstance no longer touches the keystore.
  • GleapSessionController: do not read the stored session and user in the constructor. They are loaded by ensureStateLoaded() on first access (every accessor of the session or the user goes through it), and initialize warms it up on GleapExecutor.SERIAL, so the keystore work happens on the SDK's own thread. The load is guarded by its own lock (stateLoadLock), not the controller's monitor, so the unrelated methods that lock the controller (for example setPendingIdentificationAction, called by every identifyContact) never wait for it.

No storage format change, no new public API.

Backward compatibility

  • Stored values keep the same format (AES/GCM under the same gleap_prefs_key alias, Base64(IV + ciphertext)), so everything already on users' devices is read as before, and the plain-text fallback is unchanged.
  • Behavior only changes in timing: the stored session and user are read on first use rather than inside the constructor. A caller that needs them before the warm-up finished waits for the load, and writes (mergeUserSession, setGleapUserSession, clearUserSession) load first so they cannot be overwritten by a late load.
  • Only the calls that need the stored session synchronously can still wait on the first load when made on the UI thread: getIdentity() and clearIdentity(). isUserIdentified() does not wait for the load but still reads the stored user on the calling thread, as before. initialize, identifyContact/identifyUser, updateContact and the widget open calls do not wait on it.
  • The React Native bridge runs initialize, identify, clearIdentity, getIdentity and isUserIdentified on the UI thread, which is why the load does not hold the controller lock.

Testing

  • ./gradlew :gleap:testDebugUnitTest: 216 tests, 0 failures, including two new GleapSessionControllerTest cases: the constructor does not read the store (the stored session is still returned on first use), and queueing an identify returns promptly while a load is blocked in the store.
  • ./gradlew :gleap:assembleRelease: builds.
  • Not tested on a device or emulator.
ANR stack traces (main thread, SDK 19.2.2)

Key generation on first launch (Android 16, thread state: waiting for a binder transaction):

Thread: main
at __ioctl
at ioctl
at android::IPCThreadState::transact
at android::BpBinder::transact
at android_os_BinderProxy_transact
at android.os.BinderProxy.transactNative (Native method)
at android.os.BinderProxy.transact (BinderProxy.java:736)
at android.system.keystore2.IKeystoreSecurityLevel$Stub$Proxy.generateKey (IKeystoreSecurityLevel.java:307)
at android.security.KeyStoreSecurityLevel.generateKey (KeyStoreSecurityLevel.java:149)
at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi.engineGenerateKey (AndroidKeyStoreKeyGeneratorSpi.java:402)
at javax.crypto.KeyGenerator.generateKey (KeyGenerator.java:612)
at io.gleap.GleapPreferencesHelper.getOrCreateKey (GleapPreferencesHelper.java:210)
at io.gleap.GleapPreferencesHelper.keystoreCipher (GleapPreferencesHelper.java:182)
at io.gleap.GleapPreferencesHelper.<init> (GleapPreferencesHelper.java:102)
at io.gleap.GleapPreferencesHelper.getInstance (GleapPreferencesHelper.java:112)
<app frames>

Key generation (Android 14, thread state: waiting for a binder transaction):

Thread: main
at __ioctl
at ioctl
at android::IPCThreadState::talkWithDriver
at android::IPCThreadState::waitForResponse
at android::IPCThreadState::transact
at android::BpBinder::transact
at android_os_BinderProxy_transact
at android.os.BinderProxy.transactNative (Native method)
at android.os.BinderProxy.transact (BinderProxy.java:606)
at android.system.keystore2.IKeystoreSecurityLevel$Stub$Proxy.generateKey (IKeystoreSecurityLevel.java:307)
at android.security.KeyStoreSecurityLevel.generateKey (KeyStoreSecurityLevel.java:145)
at android.security.keystore2.AndroidKeyStoreKeyGeneratorSpi.engineGenerateKey (AndroidKeyStoreKeyGeneratorSpi.java:400)
at javax.crypto.KeyGenerator.generateKey (KeyGenerator.java:612)
at io.gleap.GleapPreferencesHelper.getOrCreateKey (GleapPreferencesHelper.java:210)
at io.gleap.GleapPreferencesHelper.keystoreCipher (GleapPreferencesHelper.java:182)
<app frames>

Decrypting stored values during controller construction (Android 13, low-end device, thread state: waiting for a binder transaction):

Thread: main
at syscall
at art::ConditionVariable::WaitHoldingLocks
at artJniMethodEnd
at art_jni_method_end
at android.os.BinderProxy.transactNative (Native method)
at android.os.BinderProxy.transact (BinderProxy.java:627)
at android.system.keystore2.IKeystoreSecurityLevel$Stub$Proxy.createOperation (IKeystoreSecurityLevel.java:233)
at android.security.KeyStoreSecurityLevel.createOperation (KeyStoreSecurityLevel.java:81)
at android.security.keystore2.AndroidKeyStoreCipherSpiBase.ensureKeystoreOperationInitialized (AndroidKeyStoreCipherSpiBase.java:334)
at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineInit (AndroidKeyStoreCipherSpiBase.java:234)
at javax.crypto.Cipher.tryTransformWithProvider (Cipher.java:2997)
at javax.crypto.Cipher.tryCombinations (Cipher.java:2908)
at javax.crypto.Cipher$SpiAndProviderUpdater.updateAndGetSpiAndProvider (Cipher.java:2813)
at javax.crypto.Cipher.chooseProvider (Cipher.java:792)
at javax.crypto.Cipher.init (Cipher.java:1307)
at javax.crypto.Cipher.init (Cipher.java:1242)
at io.gleap.GleapPreferencesHelper$ValueCipher.decrypt (GleapPreferencesHelper.java:77)
at io.gleap.GleapPreferencesHelper.getString (GleapPreferencesHelper.java:142)
at io.gleap.GleapSessionController.<init> (GleapSessionController.java:38)
<app frames>

The load decrypts about 14 stored values with the keystore key. It held the
controller's monitor, so unrelated calls that lock the controller, such as
queueing an identify, waited for the whole load. The load now has its own lock.
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.

1 participant