diff --git a/changelog.txt b/changelog.txt
index 6018ebf36b..67d5eb3e62 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,6 @@
vNext
----------
+- [PATCH] Fix: WPJ's BrokerDiscovery cache crash due to shared predefined encryption key with MSAL (#3081)
- [PATCH] Fix ABBA deadlock between AzureActiveDirectory and AzureActiveDirectoryAuthority class monitors by extracting polymorphic getAuthorityURL() calls outside synchronized scopes and removing unnecessary synchronized from ConcurrentHashMap read-only methods (#3082)
- [PATCH] Optimize AcquireTokenSilent save path: replace keySet() decrypt-all with in-memory map lookup in removeAccount()/removeCredential(), add telemetry for deleteAccessTokensWithIntersectingScopes, and remove unused elapsed_time_save_account_shared_preferences attribute (#3074)
- [MINOR] Add DeviceRegistrationClientApplication as public API for OneAuth device registration with mandatory correlationId, DeviceState and DrsDiscoveryEndpoint enums (#3073)
diff --git a/common/src/main/java/com/microsoft/identity/common/components/AndroidPlatformComponentsFactory.java b/common/src/main/java/com/microsoft/identity/common/components/AndroidPlatformComponentsFactory.java
index 069323cf66..aecbd8c8a6 100644
--- a/common/src/main/java/com/microsoft/identity/common/components/AndroidPlatformComponentsFactory.java
+++ b/common/src/main/java/com/microsoft/identity/common/components/AndroidPlatformComponentsFactory.java
@@ -121,6 +121,29 @@ private static IPlatformComponents create(@NonNull final Context context,
return builder.build();
}
+ /**
+ * Creates an {@link IPlatformComponents} object from a {@link Context},
+ * with the storage encryption manager configured to always use the Android KeyStore
+ * for encryption, ignoring any predefined key set via {@link AuthenticationSettings#setSecretKey(byte[])}.
+ *
+ * This is intended for components (e.g. Device Registration API, Broker API)
+ * that should not depend on the MSAL/ADAL predefined key lifecycle.
+ *
+ * @param context an application context.
+ **/
+ public static IPlatformComponents createFromContextWithKeystoreOnlyEncryptionForStorage(
+ @NonNull final Context context) {
+ initializeGlobalStates(context);
+
+ final PlatformComponents.PlatformComponentsBuilder builder = PlatformComponents.builder();
+ fillBuilderWithBasicImplementations(builder, context, null, null);
+
+ // Override the storage supplier with a keystore-only encryption manager.
+ builder.storageSupplier(new AndroidStorageSupplier(context,
+ new AndroidAuthSdkStorageEncryptionManager(context, true)));
+ return builder.build();
+ }
+
/**
* Fill {@link PlatformComponents.PlatformComponentsBuilder}
* with Android implementations that could be shared with other Factories, i.e. Broker.
diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManager.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManager.java
index 283cdecd57..f80ac8f17a 100644
--- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManager.java
+++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManager.java
@@ -28,6 +28,8 @@
import com.microsoft.identity.common.java.crypto.StorageEncryptionManager;
import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider;
import com.microsoft.identity.common.java.crypto.key.PredefinedKeyProvider;
+import com.microsoft.identity.common.java.exception.ClientException;
+import com.microsoft.identity.common.java.exception.ErrorStrings;
import com.microsoft.identity.common.logging.Logger;
import java.util.Collections;
@@ -57,7 +59,23 @@ public class AndroidAuthSdkStorageEncryptionManager extends StorageEncryptionMan
private final ISecretKeyProvider mKeyStoreKeyProvider;
public AndroidAuthSdkStorageEncryptionManager(@NonNull final Context context) {
- if (AuthenticationSettings.INSTANCE.getSecretKeyData() == null) {
+ this(context, false);
+ }
+
+ /**
+ * @param context an application context.
+ * @param useKeystoreOnly if true, always use the KeyStore-backed key for encryption,
+ * ignoring any predefined key set via {@link AuthenticationSettings}.
+ * The predefined key is never captured in this mode.
+ * Attempting to decrypt data that was encrypted with a predefined key
+ * (U001 identifier) will throw
+ * {@link com.microsoft.identity.common.java.exception.ClientException},
+ * allowing callers (e.g. {@code EncryptedNameValueStorage}) to treat it
+ * as a cache miss and self-heal.
+ */
+ public AndroidAuthSdkStorageEncryptionManager(@NonNull final Context context,
+ final boolean useKeystoreOnly) {
+ if (useKeystoreOnly || AuthenticationSettings.INSTANCE.getSecretKeyData() == null) {
mPredefinedKeyProvider = null;
} else {
mPredefinedKeyProvider = new PredefinedKeyProvider("USER_DEFINED_KEY",
@@ -83,15 +101,15 @@ public ISecretKeyProvider getKeyProviderForEncryption() {
@Override
@NonNull
- public List getKeyProviderForDecryption(byte[] cipherText) {
- final String methodTag = TAG + ":getKeyLoaderForDecryption";
+ public List getKeyProviderForDecryption(byte[] cipherText) throws ClientException {
+ final String methodTag = TAG + ":getKeyProviderForDecryption";
final String keyIdentifier = getKeyIdentifierFromCipherText(cipherText);
if (PredefinedKeyProvider.USER_PROVIDED_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) {
if (mPredefinedKeyProvider != null) {
return Collections.singletonList(mPredefinedKeyProvider);
} else {
- throw new IllegalStateException(
+ throw new ClientException(ErrorStrings.DECRYPTION_FAILED,
"Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, " +
"but mPredefinedKeyProvider is null.");
}
diff --git a/common/src/main/java/com/microsoft/identity/deviceregistration/api/DeviceRegistrationClientApplication.kt b/common/src/main/java/com/microsoft/identity/deviceregistration/api/DeviceRegistrationClientApplication.kt
index 724060ab4b..e54aad4a9a 100644
--- a/common/src/main/java/com/microsoft/identity/deviceregistration/api/DeviceRegistrationClientApplication.kt
+++ b/common/src/main/java/com/microsoft/identity/deviceregistration/api/DeviceRegistrationClientApplication.kt
@@ -85,7 +85,7 @@ class DeviceRegistrationClientApplication {
*/
@Throws(ClientException::class)
constructor(context: Context) {
- val components = AndroidPlatformComponentsFactory.createFromContext(context)
+ val components = AndroidPlatformComponentsFactory.createFromContextWithKeystoreOnlyEncryptionForStorage(context)
mController = buildController(
context,
components,
diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManagerTest.java b/common/src/test/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManagerTest.java
index ab7399ef61..6f6e920113 100644
--- a/common/src/test/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManagerTest.java
+++ b/common/src/test/java/com/microsoft/identity/common/crypto/AndroidAuthSdkStorageEncryptionManagerTest.java
@@ -35,6 +35,8 @@
import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider;
import com.microsoft.identity.common.java.crypto.key.KeyUtil;
import com.microsoft.identity.common.java.crypto.key.PredefinedKeyProvider;
+import com.microsoft.identity.common.java.exception.ClientException;
+import com.microsoft.identity.common.java.exception.ErrorStrings;
import org.junit.Assert;
import org.junit.Before;
@@ -82,7 +84,7 @@ public void testGetEncryptionKey_PreDefinedKeyProvided() {
* try getting a decryption key when a predefined key is NOT provided.
*/
@Test
- public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() {
+ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() throws ClientException {
final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context);
final List keyproviderList = manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY);
@@ -96,7 +98,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() {
* try getting a decryption key when a predefined key is provided.
*/
@Test
- public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyProvided() {
+ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyProvided() throws ClientException {
AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY);
final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context);
final List keyproviderList = manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY);
@@ -111,18 +113,19 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyPr
* try getting a decryption key when a predefined key is NOT provided.
*/
@Test
- public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey() {
+ public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey() throws ClientException {
final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context);
try {
final List keyproviderList = manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY);
- } catch (IllegalStateException ex) {
- Assert.assertEquals(
- "Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, but mPredefinedKeyProvider is null.",
- ex.getMessage());
+ Assert.fail("Expected ClientException");
+ } catch (ClientException ex) {
+ Assert.assertEquals(ErrorStrings.DECRYPTION_FAILED, ex.getErrorCode());
+ Assert.assertTrue(ex.getMessage().contains("mPredefinedKeyProvider is null"));
}
}
- public void testGetDecryptionKey_ForUnencryptedText_returns_empty_keyprovider() {
+ @Test
+ public void testGetDecryptionKey_ForUnencryptedText_returns_empty_keyprovider() throws ClientException {
AuthenticationSettings.INSTANCE.setIgnoreKeyProviderNotFoundError(false);
final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context);
final List keyproviderList = manager.getKeyProviderForDecryption("Unencrypted".getBytes(ENCODING_UTF8));
@@ -134,7 +137,7 @@ public void testGetDecryptionKey_ForUnencryptedText_returns_empty_keyprovider()
* try getting a decryption key when a predefined key is provided.
*/
@Test
- public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey_PreDefinedKeyProvided() {
+ public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey_PreDefinedKeyProvided() throws ClientException {
AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY);
final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context);
final List keyproviderList = manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY);
@@ -143,4 +146,83 @@ public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey_PreDefinedKey
Assert.assertTrue(keyproviderList.get(0) instanceof PredefinedKeyProvider);
Assert.assertEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(keyproviderList.get(0)));
}
+
+ // ==================== useKeystoreOnly=true tests ====================
+
+ /**
+ * In useKeystoreOnly mode, getKeyProviderForEncryption() should always return the
+ * KeyStore-backed key, even when a predefined key is set in AuthenticationSettings.
+ */
+ @Test
+ public void testKeystoreOnly_GetEncryptionKey_IgnoresPredefinedKey() {
+ AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY);
+ final AndroidAuthSdkStorageEncryptionManager manager =
+ new AndroidAuthSdkStorageEncryptionManager(context, true);
+
+ final ISecretKeyProvider provider = manager.getKeyProviderForEncryption();
+ Assert.assertTrue(provider instanceof KeyStoreBackedSecretKeyProvider);
+ Assert.assertNotEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(provider));
+ }
+
+ /**
+ * In useKeystoreOnly mode, getKeyProviderForEncryption() should return the KeyStore-backed key
+ * when no predefined key is set (same as default mode).
+ */
+ @Test
+ public void testKeystoreOnly_GetEncryptionKey_NoPredefinedKey() {
+ final AndroidAuthSdkStorageEncryptionManager manager =
+ new AndroidAuthSdkStorageEncryptionManager(context, true);
+
+ final ISecretKeyProvider provider = manager.getKeyProviderForEncryption();
+ Assert.assertTrue(provider instanceof KeyStoreBackedSecretKeyProvider);
+ }
+
+ /**
+ * In useKeystoreOnly mode, when encountering data encrypted with a predefined key
+ * and the predefined key is NOT available, should throw ClientException
+ * (not IllegalStateException), so EncryptedNameValueStorage.get() can catch it.
+ */
+ @Test
+ public void testKeystoreOnly_GetDecryptionKey_PredefinedKeyData_NotAvailable() throws ClientException {
+ final AndroidAuthSdkStorageEncryptionManager manager =
+ new AndroidAuthSdkStorageEncryptionManager(context, true);
+ try {
+ manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY);
+ Assert.fail("Expected ClientException");
+ } catch (ClientException ex) {
+ Assert.assertTrue(ex.getMessage().contains("mPredefinedKeyProvider is null"));
+ }
+ }
+
+ /**
+ * In useKeystoreOnly mode, even when a predefined key IS set in AuthenticationSettings,
+ * encountering U001-encrypted data should still throw ClientException
+ * because mPredefinedKeyProvider is always null in this mode.
+ */
+ @Test
+ public void testKeystoreOnly_GetDecryptionKey_PredefinedKeyData_Available_StillThrows() throws ClientException {
+ AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY);
+ final AndroidAuthSdkStorageEncryptionManager manager =
+ new AndroidAuthSdkStorageEncryptionManager(context, true);
+ try {
+ manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY);
+ Assert.fail("Expected ClientException");
+ } catch (ClientException ex) {
+ Assert.assertTrue(ex.getMessage().contains("mPredefinedKeyProvider is null"));
+ }
+ }
+
+ /**
+ * In useKeystoreOnly mode, data encrypted with the keystore key should decrypt normally.
+ */
+ @Test
+ public void testKeystoreOnly_GetDecryptionKey_ForDataEncryptedWithKeyStoreKey() throws ClientException {
+ final AndroidAuthSdkStorageEncryptionManager manager =
+ new AndroidAuthSdkStorageEncryptionManager(context, true);
+ final List keyproviderList =
+ manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY);
+
+ Assert.assertEquals(1, keyproviderList.size());
+ Assert.assertTrue(keyproviderList.get(0) instanceof KeyStoreBackedSecretKeyProvider);
+ }
}
diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/MockData.java b/common/src/test/java/com/microsoft/identity/common/crypto/MockData.java
index ab6a661d0e..cb99b72395 100644
--- a/common/src/test/java/com/microsoft/identity/common/crypto/MockData.java
+++ b/common/src/test/java/com/microsoft/identity/common/crypto/MockData.java
@@ -29,11 +29,11 @@ private MockData(){}
// Value extracted from the legacy StorageHelper.
// Data Set 1 - Predefined key.
- static final byte[] PREDEFINED_KEY = new byte[]{22, 78, -69, -66, 84, -65, 119, -9, -34, -80, 60, 67, -12, -117, 86, -47, -84, -24, -18, 121, 70, 32, -110, 51, -93, -10, -93, -110, 124, -68, -42, -119};
+ public static final byte[] PREDEFINED_KEY = new byte[]{22, 78, -69, -66, 84, -65, 119, -9, -34, -80, 60, 67, -12, -117, 86, -47, -84, -24, -18, 121, 70, 32, -110, 51, -93, -10, -93, -110, 124, -68, -42, -119};
static final byte[] TEXT_ENCRYPTED_BY_PREDEFINED_KEY = "cE1VTAwMeHz7BCCH/27kWvMYYMsGamVenQk6w+YJ14JnFBi6fJ1D8FrdLe8ZSX/FeU1apYKsj9d1fNoMD4kR62XfPMytA3P2XpXEQtkblP6F6A5R74F".getBytes(ENCODING_UTF8);
// Data Set 2 - Another predefined key.
- static final byte[] ANOTHER_PREDEFINED_KEY = new byte[]{122, 75, 49, 112, 36, 126, 5, 35, 46, 45, -61, -61, 55, 105, 9, -123, 115, 27, 35, -54, -49, 14, -16, 49, -74, -88, -29, -15, -33, -13, 100, 118};
+ public static final byte[] ANOTHER_PREDEFINED_KEY = new byte[]{122, 75, 49, 112, 36, 126, 5, 35, 46, 45, -61, -61, 55, 105, 9, -123, 115, 27, 35, -54, -49, 14, -16, 49, -74, -88, -29, -15, -33, -13, 100, 118};
// Data Set 3 - Android KeyStore-wrapped key.
static final byte[] ANDROID_WRAPPED_KEY = new byte[]{122, 75, 49, 112, 36, 126, 5, 35, 46, 45, -61, -61, 55, 105, 9, -123, 115, 27, 35, -54, -49, 14, -16, 49, -74, -88, -29, -15, -33, -13, 100, 118};
diff --git a/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt b/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt
index 4a38b3038c..aa60aca701 100644
--- a/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt
+++ b/common/src/test/java/com/microsoft/identity/common/internal/activebrokerdiscovery/BrokerDiscoveryClientTests.kt
@@ -22,7 +22,13 @@
// THE SOFTWARE.
package com.microsoft.identity.common.internal.activebrokerdiscovery
+import android.content.Context
import android.os.Bundle
+import androidx.test.core.app.ApplicationProvider
+import com.microsoft.identity.common.adal.internal.AuthenticationSettings
+import com.microsoft.identity.common.components.AndroidStorageSupplier
+import com.microsoft.identity.common.crypto.AndroidAuthSdkStorageEncryptionManager
+import com.microsoft.identity.common.crypto.MockData
import com.microsoft.identity.common.exception.BrokerCommunicationException
import com.microsoft.identity.common.internal.broker.BrokerData
import com.microsoft.identity.common.internal.broker.BrokerData.Companion.prodCompanyPortal
@@ -30,8 +36,15 @@ import com.microsoft.identity.common.internal.broker.BrokerData.Companion.prodMi
import com.microsoft.identity.common.internal.broker.ipc.AbstractIpcStrategyWithServiceValidation
import com.microsoft.identity.common.internal.broker.ipc.BrokerOperationBundle
import com.microsoft.identity.common.internal.broker.ipc.IIpcStrategy
+import com.microsoft.identity.common.internal.cache.ClientActiveBrokerCache
+import com.microsoft.identity.common.internal.cache.SharedPreferencesFileManager
+import com.microsoft.identity.common.java.crypto.StorageEncryptionManager
+import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator
+import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider
+import com.microsoft.identity.common.java.crypto.key.PredefinedKeyProvider
import com.microsoft.identity.common.java.exception.ClientException
import com.microsoft.identity.common.java.exception.ClientException.ONLY_SUPPORTS_ACCOUNT_MANAGER_ERROR_CODE
+import com.microsoft.identity.common.java.exception.ErrorStrings
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert
@@ -45,6 +58,14 @@ import java.util.concurrent.atomic.AtomicInteger
@RunWith(RobolectricTestRunner::class)
class BrokerDiscoveryClientTests {
+ companion object {
+ /**
+ * Mirrors ClientActiveBrokerCache.BROKER_METADATA_CACHE_STORE_ON_BROKER_SDK_SIDE_STORAGE_NAME
+ * (which is private). Centralized here to reduce drift if the production name changes.
+ */
+ private const val BROKER_SDK_CACHE_FILE_NAME = "BROKER_METADATA_CACHE_STORE_ON_BROKER_SDK_SIDE"
+ }
+
/**
* Happy scenario.
* - First time querying (nothing in the cache).
@@ -1003,4 +1024,129 @@ class BrokerDiscoveryClientTests {
}
)
}
+
+ /**
+ * End-to-end test for the encryption key mismatch bug.
+ *
+ * Simulates Company Portal scenario:
+ * 1. MSAL sets a predefined key → cache data is encrypted with it (U001 prefix).
+ * 2. On next launch, WPJ API uses a different key for encryption (simulating keystore).
+ * 3. Reading cache fails decryption (ClientException caught by EncryptedNameValueStorage) → null.
+ * 4. BrokerDiscoveryClient falls back to IPC discovery — no crash.
+ * 5. After IPC, cache is re-populated with the new key and is readable.
+ *
+ * Uses real StorageEncryptionManager subclasses and ClientActiveBrokerCache.
+ * Uses a second PredefinedKeyProvider to stand in for the Android KeyStore key
+ * (which is unavailable in Robolectric).
+ */
+ @Test
+ fun testCacheDecryptionFailure_FallsBackToIpcDiscovery() {
+ val context = ApplicationProvider.getApplicationContext()
+
+ // Ensure test isolation: clear any leftover data in the broker SDK cache SharedPreferences
+ // and the SharedPreferencesFileManager singleton cache.
+ context.getSharedPreferences(BROKER_SDK_CACHE_FILE_NAME, Context.MODE_PRIVATE)
+ .edit().clear().commit()
+ SharedPreferencesFileManager.clearSingletonCache()
+ AuthenticationSettings.INSTANCE.clearSecretKeysForTestCases()
+
+ try {
+ // A mock "keystore" key provider using a different key AND a different identifier than
+ // the predefined one. PredefinedKeyProvider always uses "U001", so we need a custom
+ // ISecretKeyProvider with a distinct identifier (e.g. "KS01") to simulate the real
+ // KeyStoreBackedSecretKeyProvider behavior.
+ val mockKeystoreKeyProvider = object : ISecretKeyProvider {
+ override val alias: String = "MOCK_KEYSTORE_KEY"
+ override val keyTypeIdentifier: String = "KS01"
+ override val key: javax.crypto.SecretKey = AES256SecretKeyGenerator.generateKeyFromRawBytes(MockData.ANOTHER_PREDEFINED_KEY)
+ override val cipherTransformation: String = "AES/CBC/PKCS5Padding"
+ }
+
+ // Step 1: Write cache data using the predefined key (simulates MSAL-initialized path).
+ AuthenticationSettings.INSTANCE.setSecretKey(MockData.PREDEFINED_KEY)
+ val writeEncryptionManager = AndroidAuthSdkStorageEncryptionManager(context)
+ val writeSupplier = AndroidStorageSupplier(context, writeEncryptionManager)
+ val writeCache = ClientActiveBrokerCache.getBrokerSdkCache(writeSupplier)
+ writeCache.setCachedActiveBroker(prodMicrosoftAuthenticator)
+
+ // Verify data was written successfully.
+ Assert.assertEquals(prodMicrosoftAuthenticator, writeCache.getCachedActiveBroker())
+
+ // Step 2: Clear the predefined key and SharedPreferencesFileManager cache
+ // (simulates app restart where MSAL hasn't initialized).
+ AuthenticationSettings.INSTANCE.clearSecretKeysForTestCases()
+ SharedPreferencesFileManager.clearSingletonCache()
+
+ // Step 3: Create a "keystore-only" encryption manager that uses the mock keystore key
+ // and throws ClientException for U001-encrypted data (simulates the fix).
+ val readEncryptionManager = object : StorageEncryptionManager() {
+ override fun getKeyProviderForEncryption(): ISecretKeyProvider {
+ return mockKeystoreKeyProvider
+ }
+
+ override fun getKeyProviderForDecryption(cipherText: ByteArray): List {
+ val keyIdentifier = getKeyIdentifierFromCipherText(cipherText)
+ if (PredefinedKeyProvider.USER_PROVIDED_KEY_IDENTIFIER.equals(keyIdentifier, ignoreCase = true)) {
+ throw ClientException(
+ ErrorStrings.DECRYPTION_FAILED,
+ "Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, " +
+ "but mPredefinedKeyProvider is null."
+ )
+ }
+ // For data encrypted with the mock keystore key ("KS01"), return it.
+ return listOf(mockKeystoreKeyProvider)
+ }
+ }
+ val readSupplier = AndroidStorageSupplier(context, readEncryptionManager)
+ val readCache = ClientActiveBrokerCache.getBrokerSdkCache(readSupplier)
+
+ // Step 4: Use this cache in BrokerDiscoveryClient — should fall back to IPC, not crash.
+ val client = BrokerDiscoveryClient(
+ brokerCandidates = setOf(
+ prodMicrosoftAuthenticator, prodCompanyPortal
+ ),
+ getActiveBrokerFromAccountManager = {
+ throw IllegalStateException("Should not fall back to AccountManager when IPC succeeds")
+ },
+ ipcStrategy = object : IIpcStrategy {
+ override fun communicateToBroker(bundle: BrokerOperationBundle): Bundle {
+ val returnBundle = Bundle()
+ returnBundle.putString(
+ BrokerDiscoveryClient.ACTIVE_BROKER_PACKAGE_NAME_BUNDLE_KEY,
+ prodCompanyPortal.packageName
+ )
+ returnBundle.putString(
+ BrokerDiscoveryClient.ACTIVE_BROKER_SIGNING_CERTIFICATE_THUMBPRINT_BUNDLE_KEY,
+ prodCompanyPortal.signingCertificateThumbprint
+ )
+ return returnBundle
+ }
+ override fun isSupportedByTargetedBroker(targetedBrokerPackageName: String): Boolean {
+ return true
+ }
+ override fun getType(): IIpcStrategy.Type {
+ return IIpcStrategy.Type.CONTENT_PROVIDER
+ }
+ },
+ cache = readCache,
+ isPackageInstalled = {
+ it == prodMicrosoftAuthenticator || it == prodCompanyPortal
+ },
+ isValidBroker = { true }
+ )
+
+ // Should NOT crash — should fall back to IPC and discover Company Portal.
+ val result = client.getActiveBroker()
+ Assert.assertEquals(prodCompanyPortal, result)
+
+ // After IPC re-populates the cache, it should be readable with the mock keystore key.
+ Assert.assertEquals(prodCompanyPortal, readCache.getCachedActiveBroker())
+ } finally {
+ // Restore global state to avoid leaking into other tests.
+ context.getSharedPreferences(BROKER_SDK_CACHE_FILE_NAME, Context.MODE_PRIVATE)
+ .edit().clear().commit()
+ SharedPreferencesFileManager.clearSingletonCache()
+ AuthenticationSettings.INSTANCE.clearSecretKeysForTestCases()
+ }
+ }
}
\ No newline at end of file