From 782618f7532c753e21959886c6abb2bf1c7c35ef Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 11 Jun 2025 14:00:51 -0700 Subject: [PATCH 01/51] prototype --- .../crypto/AndroidWrappedKeyLoaderTest.java | 64 +++- ...ndroidAuthSdkStorageEncryptionManager.java | 8 +- .../crypto/AndroidKeyStoreRsaKekManager.java | 219 ++++++++++++++ .../crypto/AndroidWrappedKeyLoader.java | 11 +- .../crypto/CryptoParameterSpecFactory.kt | 255 ++++++++++++++++ .../identity/common/crypto/IKekManager.java | 53 ++++ .../crypto/NewAndroidWrappedKeyLoader.java | 276 ++++++++++++++++++ .../internal/util/AndroidKeyStoreUtil.java | 24 +- ...idAuthSdkStorageEncryptionManagerTest.java | 16 +- .../AndroidKeyStoreRsaKekManagerTest.java | 214 ++++++++++++++ .../common/crypto/IKEKGeneratorTest.java | 115 ++++++++ .../java/crypto/StorageEncryptionManager.java | 20 +- .../java/crypto/key/AES256KeyLoader.java | 40 +-- .../crypto/key/AES256SecretKeyGenerator.kt | 44 +++ .../crypto/key/AbstractSecretKeyLoader.java | 144 --------- .../java/crypto/key/ISecretKeyGenerator.kt | 10 + .../java/crypto/key/ISecretKeyLoader.kt | 62 ++++ .../common/java/crypto/key/KeyUtil.java | 4 +- .../java/crypto/key/PredefinedKeyLoader.java | 17 +- .../common/java/flighting/CommonFlight.java | 7 +- .../identity/common/java/util/FileUtil.java | 41 +++ .../java/crypto/MockAES256KeyLoader.java | 12 +- .../MockAES256KeyLoaderWithGetKeyError.java | 8 + .../crypto/MockStorageEncryptionManager.java | 18 +- .../crypto/StorageEncryptionManagerTest.java | 13 +- 25 files changed, 1457 insertions(+), 238 deletions(-) create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java create mode 100644 common/src/test/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManagerTest.java create mode 100644 common/src/test/java/com/microsoft/identity/common/crypto/IKEKGeneratorTest.java create mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt delete mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java create mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt create mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java index 177e011c89..0aa9b9e2c7 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java @@ -24,12 +24,15 @@ import android.content.Context; import android.security.KeyPairGeneratorSpec; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; import androidx.test.core.app.ApplicationProvider; import com.microsoft.identity.common.adal.internal.AuthenticationSettings; import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; +import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.util.FileUtil; @@ -40,12 +43,23 @@ import java.io.File; import java.math.BigInteger; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.MGF1ParameterSpec; import java.util.Arrays; import java.util.Date; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; import javax.security.auth.x500.X500Principal; import static com.microsoft.identity.common.java.exception.ClientException.INVALID_KEY; @@ -57,6 +71,8 @@ public class AndroidWrappedKeyLoaderTest { final String MOCK_KEY_FILE_PATH = "MOCK_KEY_FILE_PATH"; final int TEST_LOOP = 100; + final static String AES_ALGORITHM = "AES"; + @Before public void setUp() throws Exception { // Everything is on clean slate. @@ -113,7 +129,7 @@ public void testGenerateKey() throws ClientException { final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyLoader.generateRandomKey(); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, secretKey.getAlgorithm()); + Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); } @Test @@ -125,8 +141,8 @@ public void testReadKeyDirectly() throws ClientException { // They're not the same object! Assert.assertNotSame(secretKey, storedSecretKey); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, secretKey.getAlgorithm()); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, storedSecretKey.getAlgorithm()); + Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); + Assert.assertEquals(AES_ALGORITHM, storedSecretKey.getAlgorithm()); Assert.assertNotNull(secretKey.getEncoded()); Assert.assertNotNull(storedSecretKey.getEncoded()); @@ -145,7 +161,7 @@ public void testLoadKey() throws ClientException { final SecretKey key = keyLoader.getKeyCache().getData(); Assert.assertNotNull(key); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, secretKey.getAlgorithm()); + Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); Assert.assertArrayEquals(secretKey.getEncoded(), key.getEncoded()); Assert.assertEquals(secretKey.getFormat(), key.getFormat()); } @@ -260,6 +276,46 @@ public void testLoadDeletedKeyFile() throws ClientException { Assert.assertNull(key); } + @Test + public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProviderException, + NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore"); + + keyPairGenerator.initialize(new KeyGenParameterSpec.Builder( + "my_rsa_key_alias", // alias for keystore + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setKeySize(2048) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) // allow both digests + .build()); + + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Assume AES key was created elsewhere + SecretKey aesKey = new AES256SecretKeyGenerator().generateRandomKey(); + + // Use OAEPParameterSpec with SHA-256 as main digest but SHA-1 for MGF1 + OAEPParameterSpec oaepParams = new OAEPParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT); + + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding"); + cipher.init(Cipher.WRAP_MODE, keyPair.getPublic(), oaepParams); + + byte[] wrappedKey = cipher.wrap(aesKey); + + Cipher cipher2 = Cipher.getInstance("RSA/ECB/OAEPPadding"); + cipher2.init(Cipher.UNWRAP_MODE, keyPair.getPrivate(), oaepParams); + + // Unwrap the key back into SecretKey object + SecretKey unwrappedKey = (SecretKey) cipher2.unwrap(wrappedKey, "AES", Cipher.SECRET_KEY); + + // Verify the keys match + Assert.assertEquals(aesKey.getAlgorithm(), unwrappedKey.getAlgorithm()); + Assert.assertArrayEquals(aesKey.getEncoded(), unwrappedKey.getEncoded()); + } + + private AndroidWrappedKeyLoader initKeyLoaderWithKeyEntry() throws ClientException { final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey key = keyLoader.getKey(); 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 20e091cbd2..d9b14fcfc4 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 @@ -27,7 +27,7 @@ import com.microsoft.identity.common.adal.internal.AuthenticationSettings; import com.microsoft.identity.common.java.crypto.StorageEncryptionManager; import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.crypto.key.PredefinedKeyLoader; import com.microsoft.identity.common.logging.Logger; @@ -83,20 +83,20 @@ public AES256KeyLoader getKeyLoaderForEncryption() { @Override @NonNull - public List getKeyLoaderForDecryption(byte[] cipherText) { + public List getKeyLoaderForDecryption(byte[] cipherText) { final String methodTag = TAG + ":getKeyLoaderForDecryption"; final String keyIdentifier = getKeyIdentifierFromCipherText(cipherText); if (PredefinedKeyLoader.USER_PROVIDED_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) { if (mPredefinedKeyLoader != null) { - return Collections.singletonList(mPredefinedKeyLoader); + return Collections.singletonList(mPredefinedKeyLoader); } else { throw new IllegalStateException( "Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, " + "but mPredefinedKeyLoader is null."); } } else if (AndroidWrappedKeyLoader.WRAPPED_KEY_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) { - return Collections.singletonList(mKeyStoreKeyLoader); + return Collections.singletonList(mKeyStoreKeyLoader); } Logger.warn(methodTag, diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java new file mode 100644 index 0000000000..71b2e6cb1d --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.controllers.ExceptionAdapter; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.opentelemetry.AttributeName; +import com.microsoft.identity.common.java.opentelemetry.OTelUtility; +import com.microsoft.identity.common.java.opentelemetry.SpanExtension; +import com.microsoft.identity.common.java.opentelemetry.SpanName; +import com.microsoft.identity.common.java.util.StringUtil; +import com.microsoft.identity.common.logging.Logger; + +import java.security.KeyPair; +import java.security.spec.AlgorithmParameterSpec; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import javax.crypto.SecretKey; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; + + +/** + * Class responsible for generating key pairs used for wrapping secret keys. + * Handles different strategies based on API levels and feature flags. + */ +public class AndroidKeyStoreRsaKekManager implements IKekManager { + private static final String TAG = AndroidKeyStoreRsaKekManager.class.getSimpleName(); + + + /** + * Algorithm used to generate wrapping key. + */ + private static final String KEK_ALGORITHM = "RSA"; + + + private final String mKeyAlias; + + private final CryptoParameterSpecFactory mCryptoParameterSpecFactory; + + + /** + * Constructor for AndroidKeyStoreRsaKekManager. + * + * @param keyAlias The alias for the key to be generated + * @param context The context in which the key will be used, typically an Android Context + */ + public AndroidKeyStoreRsaKekManager(@NonNull final String keyAlias, + @NonNull final Context context + ) { + mKeyAlias = keyAlias; + mCryptoParameterSpecFactory = new CryptoParameterSpecFactory(context, mKeyAlias); + } + + @Override + public boolean kekExists() throws ClientException { + return AndroidKeyStoreUtil.readKey(mKeyAlias) != null; + } + + + @Override + public SecretKey unwrapKey(byte[] wrappedSecretKey, final String SecretKeyAlgorithm) throws ClientException { + final String methodTag = TAG + ":unwrapKey"; + final KeyPair keyPair = AndroidKeyStoreUtil.readKey(mKeyAlias); + if (keyPair == null) { + final ClientException clientException = new ClientException( + ClientException.KEY_LOAD_FAILURE, + "No existing keypair found for alias: " + mKeyAlias + ); + Logger.error(methodTag, clientException.getMessage(), clientException); + throw clientException; + } + final List specs = mCryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); + final LinkedList exceptions = new LinkedList<>(); + for (CryptoParameterSpecFactory.CipherSpec spec : specs) { + try { + // Attempt to unwrap the key using the current spec + return AndroidKeyStoreUtil.unwrap( + wrappedSecretKey, + SecretKeyAlgorithm, + keyPair, + spec.getTransformation(), + spec.getAlgorithmParameterSpecs() + ); + } catch (final Throwable throwable) { + Logger.warn(methodTag, "Failed to unwrap key with spec: " + spec.getTransformation()); + // Continue to the next spec if this one fails + exceptions.add(throwable); + } + } + for (final Throwable exception : exceptions) { + Logger.error(methodTag, "Exception encountered during key pair generation: " + exception.getMessage(),exception); + } + + // If we've tried all specs and failed, set span status and throw the last exception + if (exceptions.isEmpty()) { + exceptions.add( + new ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "Failed to unwrap key after trying all available specs.") + ); + } + throw ExceptionAdapter.clientExceptionFromException(exceptions.getLast()); + } + + public byte[] wrapKey(final SecretKey keyToWrap) throws ClientException { + final String methodTag = TAG + ":wrapKey"; + KeyPair keyPair = AndroidKeyStoreUtil.readKey(mKeyAlias); + if (keyPair == null) { + Logger.info(methodTag, "No existing keypair found for alias. Generating a new keypair."); + keyPair = generateKeyPair(); + } + final CryptoParameterSpecFactory.CipherSpec cipherSpecs = mCryptoParameterSpecFactory.getPrimaryCipherParameterSpec(); + return AndroidKeyStoreUtil.wrap( + keyToWrap, + keyPair, + cipherSpecs.getTransformation(), + cipherSpecs.getAlgorithmParameterSpecs() + ); + } + + @NonNull + private KeyPair generateKeyPair() throws ClientException { + final String methodTag = TAG + ":generateKeyPair"; + final Span span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name(), SpanExtension.current().getSpanContext()); + final List specs = mCryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + // Track the last exception encountered to throw if all attempts fail + final LinkedList exceptions = new LinkedList<>(); + + try (final Scope ignored = SpanExtension.makeCurrentSpan(span)) { + // Try each spec in order of priority + for (CryptoParameterSpecFactory.KeyGenSpec spec : specs) { + try { + final KeyPair keyPair = attemptKeyPairGeneration(spec.getKeyGenParameterSpec()); + + // Log the success with a descriptive name for telemetry + span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), spec.getDescription()); + Logger.info(methodTag, "Successfully generated key pair using: " + spec.getDescription()); + + // Return successful key pair + span.setStatus(StatusCode.OK); + return keyPair; + } catch (final Throwable throwable) { + // Log the failure but continue to the next spec + Logger.warn(methodTag, "Failed to generate keypair with spec: " + spec.getDescription()); + if (!StringUtil.isNullOrEmpty(throwable.getMessage())) { + span.setAttribute(AttributeName.keypair_gen_exception.name(), throwable.getMessage()); + } + exceptions.add(throwable); + } + } + for (final Throwable exception : exceptions) { + Logger.error(methodTag, "Exception encountered during key pair generation: " + exception.getMessage(),exception); + } + + // If we've tried all specs and failed, set span status and throw the last exception + if (exceptions.isEmpty()) { + exceptions.add( + new ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "Failed to generate key pair after trying all available specs.") + ); + } + span.setStatus(StatusCode.ERROR); + span.recordException(exceptions.getLast()); + Logger.error(methodTag, "Failed to generate key pair with all available specs", exceptions.getLast()); + throw ExceptionAdapter.clientExceptionFromException(exceptions.getLast()); + } finally { + span.end(); // Span is ended only once, after all attempts + } + } + + private KeyPair attemptKeyPairGeneration(@lombok.NonNull final AlgorithmParameterSpec keyPairGenSpec) throws ClientException { + final long keypairGenStartTime = System.currentTimeMillis(); + final KeyPair keyPair = AndroidKeyStoreUtil.generateKeyPair(KEK_ALGORITHM, keyPairGenSpec); + recordKeyGenerationTime(keypairGenStartTime); + return keyPair; + } + + private void recordKeyGenerationTime(long keypairGenStartTime) { + long elapsedTime = System.currentTimeMillis() - keypairGenStartTime; + SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name(), elapsedTime); + } + + @NonNull + @Override + public String getCipherTransformation() { + return mCryptoParameterSpecFactory.getPrimaryCipherParameterSpec().getTransformation(); + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java index 6e4495fdbd..c8446d3831 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java @@ -177,7 +177,6 @@ public synchronized SecretKey getKey() throws ClientException { return key; } - @Override @NonNull protected SecretKey generateRandomKey() throws ClientException { final String methodTag = TAG + ":generateRandomKey"; @@ -217,7 +216,7 @@ protected SecretKey generateRandomKey() throws ClientException { return null; } - final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, getKeySpecAlgorithm(), keyPair, WRAP_ALGORITHM); + final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, "AES", keyPair, WRAP_ALGORITHM, null); Logger.info(methodTag, "Key is loaded with thumbprint: " + KeyUtil.getKeyThumbPrint(key)); @@ -270,7 +269,7 @@ private void saveSecretKeyToStorage(@NonNull final SecretKey unencryptedKey) thr span.end(); } } - final byte[] keyWrapped = AndroidKeyStoreUtil.wrap(unencryptedKey, keyPair, WRAP_ALGORITHM); + final byte[] keyWrapped = AndroidKeyStoreUtil.wrap(unencryptedKey, keyPair, WRAP_ALGORITHM, null); FileUtil.writeDataToFile(keyWrapped, getKeyFile()); } @@ -464,4 +463,10 @@ private File getKeyFile() { mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), mFilePath); } + + @androidx.annotation.NonNull + @Override + public String getCipherAlgorithm() { + return WRAP_ALGORITHM; + } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt new file mode 100644 index 0000000000..b97354d7d5 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import android.content.Context +import android.os.Build +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.annotation.RequiresApi +import com.microsoft.identity.common.java.flighting.CommonFlight +import com.microsoft.identity.common.java.flighting.CommonFlightsManager.getFlightsProvider +import com.microsoft.identity.common.logging.Logger +import java.math.BigInteger +import java.security.spec.AlgorithmParameterSpec +import java.security.spec.MGF1ParameterSpec +import java.util.Calendar +import java.util.Locale +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.security.auth.x500.X500Principal + + + +/** + * Factory class to create various cryptographic parameter specifications + * for key generation and cipher operations. + * + * This class encapsulates the logic to determine which key generation and cipher specs + * to use based on the Android API level and flight flags. + * + * + * The Android Keystore (especially on older devices or some hardware-backed implementations) has limited support for MGF1 digests. + * + * Specifically: + * + * It supports OAEP with: + * + * Main Digest: SHA-256 ✅ + * + * MGF1 Digest: SHA-1 ✅ + * + * But not: + * + * MGF1 Digest: SHA-256 ❌ (on many devices) + * + */ +class CryptoParameterSpecFactory(private val context: Context, private val keyAlias: String) { + + companion object { + private val TAG = CryptoParameterSpecFactory::class.java.simpleName + private const val KEY_SIZE: Int = 2048 + private const val MODERN_SPEC_WITH_PURPOSE_WRAP_KEY = "modern_spec_with_wrap_key" + private const val MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY = "modern_spec_without_wrap_key" + private const val LEGACY_SPEC = "legacy_key_gen_spec" + private const val OAEP_TRANSFORMATION = "RSA/NONE/OAEPwithSHA-256andMGF1Padding" + private const val PKCS1_TRANSFORMATION = "RSA/ECB/PKCS1Padding" + + + private val oaepSpec = OAEPParameterSpec( + "SHA-256", // main digest + "MGF1", // mask generation function + MGF1ParameterSpec.SHA1, // MGF1 digest + PSource.PSpecified.DEFAULT // label (usually default) + ) + + private val legacyCipherSpec = CipherSpec( + algorithmParameterSpecs = null, + transformation = PKCS1_TRANSFORMATION + ) + + private val oaepCipherSpec = CipherSpec( + algorithmParameterSpecs = oaepSpec, + transformation = OAEP_TRANSFORMATION + ) + } + + data class KeyGenSpec( + val keyGenParameterSpec: AlgorithmParameterSpec, + val description: String + ) + + data class CipherSpec( + val algorithmParameterSpecs: AlgorithmParameterSpec?, + val transformation: String + ) + + private val keySpecWithPurposeKey = + getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) + private val keySpecWithoutPurposeKey = + getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) + private val keySpecWithOAEP = + getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) + + init { + val methodTag = "$TAG:init" + Logger.info( + methodTag, + "Initialized with keyAlias: $keyAlias, API level: ${Build.VERSION.SDK_INT}, " + + "With flight flags - PurposeWrapKey: $keySpecWithPurposeKey, " + + "WithoutPurposeKey: $keySpecWithoutPurposeKey, " + + "WithOAEP: $keySpecWithOAEP" + ) + } + + fun getPrimaryCipherParameterSpec(): CipherSpec { + val methodTag = "$TAG:getCipherTransformation" + val spec = if (keySpecWithOAEP) { + oaepCipherSpec + } else { + legacyCipherSpec + } + Logger.info(methodTag, "Using cipher transformation: ${spec.transformation}") + return spec + } + + fun getPrioritizedCipherParameterSpecs(): List { + val specs = mutableListOf() + if (keySpecWithOAEP) { + specs.add(oaepCipherSpec) + } + specs.add(legacyCipherSpec) + return specs + } + + /** + * Returns a prioritized list of AlgorithmParameterSpec objects to try in sequence. + * This helps handle fallback scenarios where the preferred spec might not work + * on all devices or with all existing keys. + * + * @return List of AlgorithmParameterSpec objects ordered by priority (highest first) + */ + fun getPrioritizedKeyGenParameterSpecs(): List { + val methodTag = "$TAG:getPrioritizedKeyGenParameterSpecs" + + val specs = mutableListOf() + // Add specs in order of preference + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && keySpecWithPurposeKey) { + // First priority: API 28+ with PURPOSE_WRAP_KEY if enabled + specs.add( + KeyGenSpec( + getKeyGenParamSpecWithPurposeWrapKey(), + MODERN_SPEC_WITH_PURPOSE_WRAP_KEY + ) + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && keySpecWithoutPurposeKey) { + // Second priority: API 23+ without PURPOSE_WRAP_KEY + specs.add( + KeyGenSpec( + getKeyGenParamSpecWithoutPurposeWrapKey(), + MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY + ) + ) + } + + // Always include legacy spec as last resort fallback + specs.add(KeyGenSpec(getLegacyKeyGenParamSpec(), LEGACY_SPEC)) + + Logger.info( + methodTag, + "Created prioritized specs list with ${specs.size} options: ${specs.joinToString { it.description }}" + ) + return specs + } + + /** + * Generate a self-signed cert and derive an AlgorithmParameterSpec from that. + * This is for the key to be generated in {@link KeyStore} via {@link KeyPairGenerator} + * Note : This is now only for API level < 23 or as fallback. + * + * @return a {@link AlgorithmParameterSpec} for the keystore key (that we'll use to wrap the secret key). + */ + private fun getLegacyKeyGenParamSpec(): AlgorithmParameterSpec { + + // Generate a self-signed cert. + val certInfo = String.format( + Locale.ROOT, "CN=%s, OU=%s", + keyAlias, + context.packageName + ) + val start = Calendar.getInstance() + val end = Calendar.getInstance() + val certValidYears = 100 + end.add(Calendar.YEAR, certValidYears) + + return KeyPairGeneratorSpec.Builder(context) + .setAlias(keyAlias) + .setSubject(X500Principal(certInfo)) + .setSerialNumber(BigInteger.ONE) + .setStartDate(start.time) + .setEndDate(end.time) + .build() + } + + + @RequiresApi(Build.VERSION_CODES.P) + private fun getKeyGenParamSpecWithPurposeWrapKey(): AlgorithmParameterSpec { + return getAlgorithmParameterSpec( + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT or + KeyProperties.PURPOSE_WRAP_KEY + ) + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun getKeyGenParamSpecWithoutPurposeWrapKey(): AlgorithmParameterSpec { + return getAlgorithmParameterSpec( + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT + ) + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private fun getAlgorithmParameterSpec(purposes: Int): AlgorithmParameterSpec { + val methodTag = "$TAG:getSpecForWrappingKey" + return if (keySpecWithOAEP) { + Logger.info(methodTag, "Using OAEP padding with SHA-256 digest") + KeyGenParameterSpec.Builder(keyAlias, purposes) + .setKeySize(KEY_SIZE) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .build() + } else { + // Fallback to legacy spec if OAEP is not enabled. + Logger.info(methodTag, "Using PKCS1 padding with SHA-256 and SHA-512 digests") + KeyGenParameterSpec.Builder(keyAlias, purposes) + .setKeySize(KEY_SIZE) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .build() + } + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java new file mode 100644 index 0000000000..bb5bcb5139 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import androidx.annotation.NonNull; + +import com.microsoft.identity.common.java.exception.ClientException; + +import javax.crypto.SecretKey; + +/** + * Interface for key encryption key (KEK) generation. + * Defines the contract for generating key pairs used to wrap/unwrap secret keys. + * Implementations can handle different algorithms and API versions. + */ +public interface IKekManager { + + /** + * Gets the appropriate cipher transformation to use with the generated keys. + * The transformation string specifies the algorithm, mode, and padding to be + * used for encryption/decryption operations. + * + * @return The cipher transformation string in the format "algorithm/mode/padding" + */ + @NonNull + String getCipherTransformation(); + + byte [] wrapKey(final SecretKey keyToWrap) throws ClientException; + + SecretKey unwrapKey(final byte [] wrappedSecretKey, final String SecretKeyAlgorithm) throws ClientException; + + boolean kekExists() throws ClientException; +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java new file mode 100644 index 0000000000..c142c2f32d --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import android.content.Context; + +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; +import com.microsoft.identity.common.java.crypto.key.KeyUtil; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.util.CachedData; +import com.microsoft.identity.common.java.util.FileUtil; +import com.microsoft.identity.common.logging.Logger; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +import javax.crypto.SecretKey; + +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import lombok.NonNull; + +/** + * This class doesn't really use the KeyStore-generated key directly. + *

+ * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key + * before it get saved to the file. + */ +public class NewAndroidWrappedKeyLoader extends AES256KeyLoader { + private static final String TAG = NewAndroidWrappedKeyLoader.class.getSimpleName() + "#"; + + /** + * Should KeyStore and key file check for validity before every key load be skipped. + */ + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + public static boolean sSkipKeyInvalidationCheck = false; + + /** + * Indicate that token item is encrypted with the key loaded in this class. + */ + public static final String WRAPPED_KEY_KEY_IDENTIFIER = "A001"; + + // Exposed for testing only. + public static final int KEY_FILE_SIZE = 1024; + + // Exposed for testing only. + public static final String SECRET_KEY_ALGORITHM_FILE = "key_algorithm_file"; + + private final Context mContext; + + /** + * Name of the key itself. Must be unique. + */ + private final String mAlias; + + private final IKekManager mKekManager; + + /** + * Name of the file contains the wrapped symmetric key used for encryption/decryption. + * Must be unique. + */ + private final String mFilePath; + + private final CachedData mKeyCache = new CachedData() { + @Override + public SecretKey getData() { + if (!sSkipKeyInvalidationCheck && + (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !getKeyFile().exists())) { + this.clear(); + } + return super.getData(); + } + }; + + // Exposed for testing only. + @NonNull + /* package */ CachedData getKeyCache() { + return mKeyCache; + } + + /** + * Default constructor + * + * @param alias Alias(name) of the wrapping key. + * @param filePath Path to the file for storing the wrapped key. + * @param context Android's {@link Context} + */ + public NewAndroidWrappedKeyLoader(@NonNull final String alias, + @NonNull final String filePath, + @NonNull final Context context) { + mAlias = alias; + mFilePath = filePath; + mContext = context; + mKekManager = new AndroidKeyStoreRsaKekManager(mAlias, mContext); + } + + @Override + @NonNull + public String getAlias() { + return mAlias; + } + + @Override + @NonNull + public String getKeyTypeIdentifier() { + return WRAPPED_KEY_KEY_IDENTIFIER; + } + + /** + * If key is already generated, that one will be returned. + * Otherwise, generate a new one and return. + */ + @Override + @NonNull + public synchronized SecretKey getKey() throws ClientException { + SecretKey key = mKeyCache.getData(); + + if (key == null) { + key = readSecretKeyFromStorage(); + } + + // If key doesn't exist, generate a new one. + if (key == null) { + key = generateRandomKey(); + } + + mKeyCache.setData(key); + return key; + } + + @NonNull + protected SecretKey generateRandomKey() throws ClientException { + final String methodTag = TAG + ":generateRandomKey"; + + final SecretKey key = getSecretKeyGenerator().generateRandomKey(); + saveSecretKeyToStorage(key, key.getAlgorithm()); + + Logger.info(methodTag, "New key is generated with thumbprint: " + + KeyUtil.getKeyThumbPrint(key)); + + return key; + } + + /** + * Load the saved keystore-encrypted key. Will only do read operation. + * + * @return SecretKey. Null if there isn't any. + */ + @Nullable + /* package */ synchronized SecretKey readSecretKeyFromStorage() throws ClientException { + final String methodTag = TAG + ":readSecretKeyFromStorage"; + try { + + if (!mKekManager.kekExists()) { + Logger.info(methodTag, "key does not exist in keystore"); + deleteSecretKeyFromStorage(); + return null; + } + + final byte[] wrappedSecretKey = FileUtil.readFromFile(getKeyFile(), KEY_FILE_SIZE); + if (wrappedSecretKey == null) { + Logger.warn(methodTag, "Key file is empty"); + // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache + // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. + FileUtil.deleteFile(getKeyFile()); + FileUtil.deleteFile(getKeyAlgorithmFile()); + mKeyCache.clear(); + return null; + } + + String keyAlgorithm = FileUtil.readStringFromFile(getKeyAlgorithmFile()); + if (keyAlgorithm == null || keyAlgorithm.isEmpty()) { + keyAlgorithm = getSecretKeyGenerator().getKeyAlgorithm(); + } + + + final SecretKey key = mKekManager.unwrapKey(wrappedSecretKey, keyAlgorithm); + + + Logger.info(methodTag, "Key is loaded with thumbprint: " + + KeyUtil.getKeyThumbPrint(key)); + + return key; + } catch (final ClientException e) { + // Reset KeyPair info so that new request will generate correct KeyPairs. + // All tokens with previous SecretKey are not possible to decrypt. + Logger.warn(methodTag, "Error when loading key from Storage, " + + "wipe all existing key data "); + deleteSecretKeyFromStorage(); + throw e; + } + } + + /** + * Encrypt the given unencrypted symmetric key with Keystore key and save to storage. + */ + private void saveSecretKeyToStorage(@NonNull final SecretKey unencryptedKey, + @NonNull final String keyAlgorithm) throws ClientException { + /* + * !!WARNING!! + * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring + * the sharedUserId attribute in their Android Manifest file. If multiple apps reference + * the same value for sharedUserId and are signed with the same keys, they will use + * the same AndroidKeyStore and may obtain access to the files and shared preferences + * of other applications by invoking createPackageContext. + * + * Support for sharedUserId is deprecated, however some applications still use this Android capability. + * See: https://developer.android.com/guide/topics/manifest/manifest-element + * + * To address apps in this scenario we will attempt to load an existing KeyPair + * instead of immediately generating a new key pair. This will use the same keypair + * to encrypt the symmetric key generated separately for each + * application using a shared linux user id... and avoid these applications from + * stomping/overwriting one another's keypair. + */ + final byte[] keyWrapped = mKekManager.wrapKey(unencryptedKey); + FileUtil.writeDataToFile(keyWrapped, getKeyFile()); + FileUtil.writeStringToFile(keyAlgorithm, getKeyAlgorithmFile()); + } + /** + * Wipe all the data associated from this key. + */ + // VisibleForTesting + public void deleteSecretKeyFromStorage() throws ClientException { + AndroidKeyStoreUtil.deleteKey(mAlias); + FileUtil.deleteFile(getKeyFile()); + FileUtil.deleteFile(getKeyAlgorithmFile()); + mKeyCache.clear(); + } + + + /** + * Get the file that stores the wrapped key. + */ + private File getKeyFile() { + return new File( + mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), + mFilePath); + } + + /** + * Get the file that stores the wrapped key. + */ + private File getKeyAlgorithmFile() { + return new File( + mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), + SECRET_KEY_ALGORITHM_FILE); + } + + @Override + public @NotNull String getCipherAlgorithm() { + return mKekManager.getCipherTransformation(); + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index c4092efb33..7fe243b548 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -46,6 +46,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.spec.AlgorithmParameterSpec; +import java.util.List; import java.util.Locale; import javax.crypto.Cipher; @@ -237,7 +238,6 @@ public static synchronized KeyPair readKey(@NonNull final String keyAlias) Logger.verbose(methodTag, "Public key entry doesn't exist."); return null; } - return new KeyPair(cert.getPublicKey(), (PrivateKey) privateKey); } catch (final RuntimeException e) { // There is an issue in android keystore that resets keystore @@ -354,7 +354,8 @@ public static synchronized void deleteKey( */ public static synchronized byte[] wrap(@NonNull final SecretKey key, @NonNull final KeyPair keyToWrap, - @NonNull final String wrapAlgorithm) + @NonNull final String wrapAlgorithm, + @Nullable final AlgorithmParameterSpec algorithmParameterSpec) throws ClientException { final String methodTag = TAG + ":wrap"; @@ -363,7 +364,11 @@ public static synchronized byte[] wrap(@NonNull final SecretKey key, try { Logger.verbose(methodTag, "Wrap secret key with a KeyPair."); final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); - wrapCipher.init(Cipher.WRAP_MODE, keyToWrap.getPublic()); + if (algorithmParameterSpec != null) { + wrapCipher.init(Cipher.WRAP_MODE, keyToWrap.getPublic(), algorithmParameterSpec); + } else { + wrapCipher.init(Cipher.WRAP_MODE, keyToWrap.getPublic()); + } return wrapCipher.wrap(key); } catch (final NoSuchPaddingException e) { errCode = NO_SUCH_PADDING; @@ -408,15 +413,20 @@ public static synchronized byte[] wrap(@NonNull final SecretKey key, * @return the unwrapped key. */ public static synchronized SecretKey unwrap(@NonNull final byte[] wrappedKeyBlob, - @NonNull final String wrappedKeyAlgorithm, - @NonNull final KeyPair keyPairForUnwrapping, - @NonNull final String wrapAlgorithm) throws ClientException { + @NonNull final String wrappedKeyAlgorithm, + @NonNull final KeyPair keyPairForUnwrapping, + @NonNull final String wrapAlgorithm, + @Nullable final AlgorithmParameterSpec algorithmParameterSpec) throws ClientException { final String methodTag = TAG + ":unwrap"; final Throwable exception; final String errCode; try { final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); - wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate()); + if (algorithmParameterSpec != null) { + wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate(), algorithmParameterSpec); + } else { + wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate()); + } return (SecretKey) wrapCipher.unwrap(wrappedKeyBlob, wrappedKeyAlgorithm, Cipher.SECRET_KEY); } catch (final IllegalArgumentException e) { // There is issue with Android KeyStore when lock screen type is changed which could 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 f1e0c4510b..73a508df3e 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 @@ -32,7 +32,7 @@ import androidx.test.core.app.ApplicationProvider; import com.microsoft.identity.common.adal.internal.AuthenticationSettings; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.crypto.key.KeyUtil; import com.microsoft.identity.common.java.crypto.key.PredefinedKeyLoader; @@ -62,7 +62,7 @@ public void setUp() { public void testGetEncryptionKey() { final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final AbstractSecretKeyLoader loader = manager.getKeyLoaderForEncryption(); + final ISecretKeyLoader loader = manager.getKeyLoaderForEncryption(); Assert.assertTrue(loader instanceof AndroidWrappedKeyLoader); Assert.assertNotEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(loader)); } @@ -72,7 +72,7 @@ public void testGetEncryptionKey_PreDefinedKeyProvided() { AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final AbstractSecretKeyLoader loader = manager.getKeyLoaderForEncryption(); + final ISecretKeyLoader loader = manager.getKeyLoaderForEncryption(); Assert.assertTrue(loader instanceof PredefinedKeyLoader); Assert.assertEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(loader)); } @@ -84,7 +84,7 @@ public void testGetEncryptionKey_PreDefinedKeyProvided() { @Test public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() { final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); Assert.assertEquals(1, keyLoaderList.size()); Assert.assertTrue(keyLoaderList.get(0) instanceof AndroidWrappedKeyLoader); @@ -99,7 +99,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() { public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyProvided() { AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); Assert.assertEquals(1, keyLoaderList.size()); Assert.assertTrue(keyLoaderList.get(0) instanceof AndroidWrappedKeyLoader); @@ -114,7 +114,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyPr public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey() { final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); try { - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); } catch (IllegalStateException ex) { Assert.assertEquals( "Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, but mPredefinedKeyLoader is null.", @@ -125,7 +125,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey() { public void testGetDecryptionKey_ForUnencryptedText_returns_empty_keyloader() { AuthenticationSettings.INSTANCE.setIgnoreKeyLoaderNotFoundError(false); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption("Unencrypted".getBytes(ENCODING_UTF8)); + final List keyLoaderList = manager.getKeyLoaderForDecryption("Unencrypted".getBytes(ENCODING_UTF8)); Assert.assertEquals(0, keyLoaderList.size()); } @@ -137,7 +137,7 @@ public void testGetDecryptionKey_ForUnencryptedText_returns_empty_keyloader() { public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey_PreDefinedKeyProvided() { AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); Assert.assertEquals(1, keyLoaderList.size()); Assert.assertTrue(keyLoaderList.get(0) instanceof PredefinedKeyLoader); diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManagerTest.java b/common/src/test/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManagerTest.java new file mode 100644 index 0000000000..a0c01f361b --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManagerTest.java @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import android.content.Context; + +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.SecretKey; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test class for {@link AndroidKeyStoreRsaKekManager} + * focusing on different padding modes and feature flag behavior. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) +public class AndroidKeyStoreRsaKekManagerTest { + + private static final String TEST_KEY_ALIAS = "test-kek-manager-key"; + + @Mock + private Context mMockContext; + + @Mock + private IFlightsProvider mMockFlightsProvider; + + private IFlightsProvider mOriginalFlightsProvider; + private AndroidKeyStoreRsaKekManager mKekManager; + private KeyPair mMockKeyPair; + private SecretKey mMockSecretKey; + + @Before + public void setUp() throws NoSuchAlgorithmException { + MockitoAnnotations.initMocks(this); + + // Store original flights provider + mOriginalFlightsProvider = CommonFlightsManager.INSTANCE.getFlightsProvider(); + + // Set mock flights provider + //CommonFlightsManager.INSTANCE.setFlightsProvider(mMockFlightsProvider); + + // Create a real KeyPair for testing + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + mMockKeyPair = keyPairGenerator.generateKeyPair(); + + // Create a mock SecretKey + mMockSecretKey = mock(SecretKey.class); + when(mMockSecretKey.getAlgorithm()).thenReturn("AES"); + + // Create AndroidKeyStoreRsaKekManager + mKekManager = new AndroidKeyStoreRsaKekManager(TEST_KEY_ALIAS, mMockContext); + } + + @After + public void tearDown() { + // Restore original flights provider + // CommonFlightsManager.INSTANCE.setFlightsProvider(mOriginalFlightsProvider); + } + + @Test + public void testGetCipherTransformation_WithOAEPEnabled() { + // Enable OAEP with SHA and MGF1 padding + when(mMockFlightsProvider.isFlightEnabled(eq(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING))).thenReturn(true); + + // Test transformation + String transformation = mKekManager.getCipherTransformation(); + assertEquals("RSA/NONE/OAEPwithSHA-1andMGF1Padding", transformation); + } + + @Test + public void testGetCipherTransformation_WithOAEPDisabled() { + // Disable OAEP with SHA and MGF1 padding + when(mMockFlightsProvider.isFlightEnabled(eq(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING))).thenReturn(false); + + // Test transformation + String transformation = mKekManager.getCipherTransformation(); + assertEquals("RSA/ECB/PKCS1Padding", transformation); + } + + @Test + public void testKekExists() throws ClientException { + try (MockedStatic mockKeyStoreUtil = Mockito.mockStatic(AndroidKeyStoreUtil.class)) { + // Set up mock to return keypair + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.readKey(anyString())).thenReturn(mMockKeyPair); + + // Test wrapper key exists + boolean exists = mKekManager.kekExists(); + assertTrue(exists); + } + } + + @Test + public void testKekDoesNotExist() throws ClientException { + try (MockedStatic mockKeyStoreUtil = Mockito.mockStatic(AndroidKeyStoreUtil.class)) { + // Set up mock to return null + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.readKey(anyString())).thenReturn(null); + + // Test wrapper key does not exist + boolean exists = mKekManager.kekExists(); + assertFalse(exists); + } + } + + @Test + public void testUnwrapKey_FallbackChain() throws ClientException { + try (MockedStatic mockKeyStoreUtil = Mockito.mockStatic(AndroidKeyStoreUtil.class)) { + // Enable OAEP padding + when(mMockFlightsProvider.isFlightEnabled(eq(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING))).thenReturn(true); + + // Set up mock to return keypair + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.readKey(anyString())).thenReturn(mMockKeyPair); + + // Create a wrapped key (simulated) + byte[] wrappedKey = new byte[256]; + + // First: primary transformation fails + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.unwrap( + eq(wrappedKey), + eq("AES"), + eq(mMockKeyPair), + eq("RSA/NONE/OAEPwithSHA-1andMGF1Padding"))) + .thenThrow(new ClientException("Incompatible padding mode", "Test exception")); + + // Second: SHA-1 fallback fails + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.unwrap( + eq(wrappedKey), + eq("AES"), + eq(mMockKeyPair), + eq("RSA/NONE/OAEPwithSHA-1andMGF1Padding"))) + .thenThrow(new ClientException("Incompatible padding mode", "Test exception")); + + // Third: PKCS1Padding succeeds + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.unwrap( + eq(wrappedKey), + eq("AES"), + eq(mMockKeyPair), + eq("RSA/ECB/PKCS1Padding"))) + .thenReturn(mMockSecretKey); + + // Test unwrap key + SecretKey unwrappedKey = mKekManager.unwrapKey(wrappedKey, "AES"); + + // Verify the unwrapped key is the same as our mock + assertSame(mMockSecretKey, unwrappedKey); + } + } + + @Test + public void testWrapKey() throws ClientException { + try (MockedStatic mockKeyStoreUtil = Mockito.mockStatic(AndroidKeyStoreUtil.class)) { + // Set up mock to return keypair + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.readKey(anyString())).thenReturn(mMockKeyPair); + + // Set up mock for wrap operation + byte[] expectedWrappedKey = new byte[256]; + mockKeyStoreUtil.when(() -> AndroidKeyStoreUtil.wrap(eq(mMockSecretKey), eq(mMockKeyPair), anyString())) + .thenReturn(expectedWrappedKey); + + // Test wrap key + byte[] wrappedKey = mKekManager.wrapKey(mMockSecretKey); + + // Verify the wrapped key + assertSame(expectedWrappedKey, wrappedKey); + } + } +} diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/IKEKGeneratorTest.java b/common/src/test/java/com/microsoft/identity/common/crypto/IKEKGeneratorTest.java new file mode 100644 index 0000000000..bee0640478 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/crypto/IKEKGeneratorTest.java @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import android.content.Context; + +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + + +@RunWith(RobolectricTestRunner.class) +public class IKEKGeneratorTest { + + private static final String TEST_KEY_ALIAS = "test-kek-alias"; + + @Mock + private Context mMockContext; + + @Mock + private IFlightsProvider mMockFlightsProvider; + + private IFlightsProvider mOriginalFlightsProvider; + private IKekManager mKekManager; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + // Store original flights provider + mOriginalFlightsProvider = CommonFlightsManager.INSTANCE.getFlightsProvider(); + + // Set mock flights provider + //CommonFlightsManager.INSTANCE.setFlightsProvider(mMockFlightsProvider); + + // Create KEKGenerator + mKekManager = new AndroidKeyStoreRsaKekManager(TEST_KEY_ALIAS, mMockContext); + } + + @After + public void tearDown() { + // Restore original flights provider + //CommonFlightsManager.INSTANCE.setFlightsProvider(mOriginalFlightsProvider); + } + + @Test + public void testGetCipherTransformation_WithOAEPEnabled() { + // Enable OAEP with SHA and MGF1 padding + when(mMockFlightsProvider.isFlightEnabled(eq(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING))).thenReturn(true); + + // Test transformation + String transformation = mKekManager.getCipherTransformation(); + assertEquals("RSA/NONE/OAEPwithSHA-1andMGF1Padding", transformation); + } + + @Test + public void testGetCipherTransformation_WithOAEPDisabled() { + // Disable OAEP with SHA and MGF1 padding + when(mMockFlightsProvider.isFlightEnabled(eq(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING))).thenReturn(false); + + // Test transformation + String transformation = mKekManager.getCipherTransformation(); + assertEquals("RSA/ECB/PKCS1Padding", transformation); + } + + // Note: The following test is commented out because it would require a real Android KeyStore + // which is not available in unit tests. This would be better as an instrumented test. + /* + @Test + public void testGenerateNewKeyPair() throws ClientException { + // Enable OAEP with SHA and MGF1 padding + when(mMockFlightsProvider.isFlightEnabled(eq(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING))).thenReturn(true); + + // Generate key pair + KeyPair keyPair = mKekGenerator.generateNewKeyPair(); + + // Verify key pair is not null + assertNotNull(keyPair); + assertNotNull(keyPair.getPublic()); + assertNotNull(keyPair.getPrivate()); + } + */ +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java index cd7fcc5dc2..3d5f85c7a6 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java @@ -38,7 +38,7 @@ import com.microsoft.identity.common.java.base64.Base64Flags; import com.microsoft.identity.common.java.base64.Base64Util; import com.microsoft.identity.common.java.controllers.ExceptionAdapter; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.crypto.key.KeyUtil; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; @@ -128,7 +128,7 @@ public byte[] encrypt(final byte[] plaintext) final Throwable exception; // load key for encryption if not loaded - final AbstractSecretKeyLoader keyLoader = getKeyLoaderForEncryption(); + final ISecretKeyLoader keyLoader = getKeyLoaderForEncryption(); if (keyLoader == null) { // Developer error. Throw. throw new IllegalStateException("Cannot find a matching Keyloader."); @@ -212,14 +212,14 @@ public byte[] decrypt(final byte[] cipherText) throws ClientException { return cipherText; } - final List keysForDecryption = getKeyLoaderForDecryption(cipherText); + final List keysForDecryption = getKeyLoaderForDecryption(cipherText); if (keysForDecryption.size() == 0) { // Developer error. Throw. throw new IllegalStateException("Cannot find a matching Keyloader."); } final List suppressedException = new ArrayList<>(); - for (final AbstractSecretKeyLoader keyLoader : keysForDecryption) { + for (final ISecretKeyLoader keyLoader : keysForDecryption) { try { return decryptWithSecretKey(dataBytes, keyLoader); } catch (final Throwable e) { @@ -271,14 +271,14 @@ public IKeyAccessor generateDerivedKey(byte[] label, byte[] ctx, CryptoSuite sui } /** - * Decrypted the given encrypted blob with a key from {@link AbstractSecretKeyLoader} + * Decrypted the given encrypted blob with a key from {@link ISecretKeyLoader} * * @param encryptedBlobWithoutEncodeVersion the encrypted blob with the format of * [KeyIdentifier][encryptedData][iv][MACDigest]. - * @param keyLoader a {@link AbstractSecretKeyLoader} to load the decryption key from. + * @param keyLoader a {@link ISecretKeyLoader} to load the decryption key from. */ private byte[] decryptWithSecretKey(final byte[] encryptedBlobWithoutEncodeVersion, - @NonNull final AbstractSecretKeyLoader keyLoader) + @NonNull final ISecretKeyLoader keyLoader) throws ClientException { final String errCode; final Throwable exception; @@ -478,7 +478,7 @@ private void assertHMac(final byte[] encryptedBlob, // It does not fail fast on the first not equal byte to protect against // timing attack. for (int i = start; i < end; i++) { - result |= expected[i - start] ^ encryptedBlob[i]; + result |= (byte) (expected[i - start] ^ encryptedBlob[i]); } if (result != 0) { @@ -492,7 +492,7 @@ private void assertHMac(final byte[] encryptedBlob, * @return a SecretKey loader. */ @NonNull - public abstract AbstractSecretKeyLoader getKeyLoaderForEncryption() throws ClientException; + public abstract ISecretKeyLoader getKeyLoaderForEncryption() throws ClientException; /** * Identify the encrypted blob and return a list of potential candidate key loaders for decryption. @@ -501,5 +501,5 @@ private void assertHMac(final byte[] encryptedBlob, * @return a prioritized list of SecretKey (earlier keys is more likely to be the correct one). **/ @NonNull - abstract public List getKeyLoaderForDecryption(final byte[] cipherText) throws ClientException; + abstract public List getKeyLoaderForDecryption(final byte[] cipherText) throws ClientException; } diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java index a6e0cc4b4e..f35c18e20d 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java @@ -22,42 +22,12 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.crypto.key; -import lombok.NonNull; +import org.jetbrains.annotations.NotNull; -public abstract class AES256KeyLoader extends AbstractSecretKeyLoader { - private static final String TAG = AES256KeyLoader.class.getSimpleName(); - /** - * Key size - */ - private static final int KEY_SIZE = 256; - - /** - * Key spec algorithm. - */ - public static final String AES_ALGORITHM = "AES"; - - /** - * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in - * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here - * probably doing PKCS7. We decide to go with Java default string. - */ - private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; - - @Override - @NonNull - public String getKeySpecAlgorithm() { - return AES_ALGORITHM; - } - - @Override - @NonNull - public String getCipherAlgorithm(){ - return CIPHER_ALGORITHM; - } - - @Override - protected int getKeySize() { - return KEY_SIZE; +public abstract class AES256KeyLoader implements ISecretKeyLoader { + @NotNull + public ISecretKeyGenerator getSecretKeyGenerator() { + return new AES256SecretKeyGenerator(); } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt new file mode 100644 index 0000000000..70b1628107 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt @@ -0,0 +1,44 @@ +package com.microsoft.identity.common.java.crypto.key + +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.logging.Logger +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +class AES256SecretKeyGenerator : ISecretKeyGenerator { + + companion object { + private val TAG = AES256SecretKeyGenerator::class.java.simpleName + } + + override val keySize: Int + get() = 256 + + override val keyAlgorithm: String + get() = "AES" + + @Throws (ClientException::class) + override fun generateRandomKey(): SecretKey { + val methodTag = "$TAG:generateRandomKey" + try { + val keygen = KeyGenerator.getInstance(keyAlgorithm) + keygen.init(keySize, SecureRandom()) + return keygen.generateKey() + } catch (e: NoSuchAlgorithmException) { + val clientException = ClientException( + ClientException.NO_SUCH_ALGORITHM, + e.message, + e + ) + Logger.error(methodTag, clientException.errorCode, e) + throw clientException + } + } + + override fun generateKeyFromRawBytes(rawBytes: ByteArray): SecretKey { + return SecretKeySpec(rawBytes, keyAlgorithm) + } +} \ No newline at end of file diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java deleted file mode 100644 index c9555c2f2d..0000000000 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.java.crypto.key; - -import com.microsoft.identity.common.java.base64.Base64Flags; -import com.microsoft.identity.common.java.base64.Base64Util; -import com.microsoft.identity.common.java.crypto.StorageEncryptionManager; -import com.microsoft.identity.common.java.exception.ClientException; -import com.microsoft.identity.common.java.logging.Logger; - -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; - -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; - -import lombok.NonNull; - -import static com.microsoft.identity.common.java.exception.ClientException.NO_SUCH_ALGORITHM; - -/** - * Abstracts how a {@link SecretKey} is loaded/cached/sourced/used. - */ -public abstract class AbstractSecretKeyLoader { - private static final String TAG = AbstractSecretKeyLoader.class.getSimpleName(); - - /** - * Returns this key's alias/name. - * Each key will have a unique alias/name. - */ - @NonNull - public abstract String getAlias(); - - /** - * Returns the key. - */ - @NonNull - public abstract SecretKey getKey() throws ClientException; - - /** - * Returns the Algorithm of this key. - * This must be compatible with {@link KeyGenerator#getInstance(String)} - */ - @NonNull - protected abstract String getKeySpecAlgorithm(); - - /** - * Returns the size of this key. - * This must be compatible with {@link KeyGenerator#init(int, SecureRandom)} )} - */ - protected abstract int getKeySize(); - - /** - * Gets an identifier of this key type. - * This might be padded into the encrypted string. - */ - @NonNull - public abstract String getKeyTypeIdentifier(); - - /** - * Gets the cipher algorithm that is meant to be used with this key type. - */ - @NonNull - public abstract String getCipherAlgorithm(); - - /** - * Generate a random AES-256 secret key. - * - * @return SecretKey. - */ - @NonNull - protected SecretKey generateRandomKey() throws ClientException { - final String methodName = ":generateRandomKey"; - - try { - final KeyGenerator keygen = KeyGenerator.getInstance(getKeySpecAlgorithm()); - keygen.init(getKeySize(), new SecureRandom()); - return keygen.generateKey(); - } catch (final NoSuchAlgorithmException e) { - final ClientException clientException = new ClientException( - NO_SUCH_ALGORITHM, - e.getMessage(), - e - ); - - Logger.error( - TAG + methodName, - clientException.getErrorCode(), - e - ); - - throw clientException; - } - } - - /** - * Generate a random AES-256 secret key from rawbytes. - *

- * If a non AES-256 rawBytes is provided, this will still return a SecretKey, - * but an exception would be thrown in {@link StorageEncryptionManager} - * during encryption/decryption. - * - * @return SecretKey. - */ - @NonNull - protected SecretKey generateKeyFromRawBytes(@NonNull final byte[] rawBytes) { - return new SecretKeySpec(rawBytes, getKeySpecAlgorithm()); - } - - /** - * Serializes a {@link SecretKey} into a {@link String}. - */ - public String serializeSecretKey(@NonNull final SecretKey key) { - return Base64Util.encodeToString(key.getEncoded(), Base64Flags.DEFAULT); - } - - /** - * Deserializes a {@link String} into a {@link SecretKey}. - */ - public SecretKey deserializeSecretKey(@NonNull final String serializedKey) { - return generateKeyFromRawBytes(Base64Util.decode(serializedKey, Base64Flags.DEFAULT)); - } -} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt new file mode 100644 index 0000000000..c585c1c8fc --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt @@ -0,0 +1,10 @@ +package com.microsoft.identity.common.java.crypto.key + +import javax.crypto.SecretKey + +interface ISecretKeyGenerator { + val keySize: Int + val keyAlgorithm: String + fun generateRandomKey(): SecretKey + fun generateKeyFromRawBytes(rawBytes: ByteArray): SecretKey +} \ No newline at end of file diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt new file mode 100644 index 0000000000..d4a6505781 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.crypto.key + +import com.microsoft.identity.common.java.exception.ClientException +import javax.crypto.SecretKey + +/** + * Interface defining how a [SecretKey] is loaded/cached/sourced/used. + * Implementations of this interface provide concrete strategies for key generation, + * loading, and management across different platforms and API levels. + */ +interface ISecretKeyLoader { + /** + * Returns this key's alias/name. + * Each key will have a unique alias/name. + * + * @return The key alias. + */ + val alias: String + + /** + * Gets an identifier of this key type. + * This might be padded into the encrypted string. + * + * @return The key type identifier. + */ + val keyTypeIdentifier: String + + /** + * Gets the cipher algorithm that is meant to be used with this key type. + * + * @return The cipher algorithm name. + */ + val cipherAlgorithm: String + + + val secretKeyGenerator: ISecretKeyGenerator + + @get:Throws(ClientException::class) + val key: SecretKey +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java index 4219d64c88..235c770d73 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java @@ -64,12 +64,12 @@ public class KeyUtil { private static final String HMAC_KEYSPEC_ALGORITHM = "AES"; /** - * Derive a thumbprint from the given {@link AbstractSecretKeyLoader}. + * Derive a thumbprint from the given {@link ISecretKeyLoader}. * * @param keyLoader ISecretKeyLoader to obtain the key (calculate the thumbprint from). * @return a thumbprint. Will return {@link KeyUtil#UNKNOWN_THUMBPRINT} if it fails to derived one. */ - public static String getKeyThumbPrint(final @NonNull AbstractSecretKeyLoader keyLoader) { + public static String getKeyThumbPrint(final @NonNull ISecretKeyLoader keyLoader) { final String methodName = ":getKeyThumbPrint"; try { return getKeyThumbPrint(keyLoader.getKey()); diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java index 25f12832b7..72363e2950 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java @@ -31,6 +31,13 @@ */ public class PredefinedKeyLoader extends AES256KeyLoader { + /** + * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in + * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here + * probably doing PKCS7. We decide to go with Java default string. + */ + private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; + /** * Indicate that the token item is encrypted with the user provided key. */ @@ -40,9 +47,9 @@ public class PredefinedKeyLoader extends AES256KeyLoader { private final SecretKey mKey; public PredefinedKeyLoader(@NonNull final String alias, - @NonNull final byte[] rawBytes) { + final byte @NonNull [] rawBytes) { mAlias = alias; - mKey = generateKeyFromRawBytes(rawBytes); + mKey = getSecretKeyGenerator().generateKeyFromRawBytes(rawBytes); } @Override @@ -62,4 +69,10 @@ public SecretKey getKey() { public String getKeyTypeIdentifier() { return USER_PROVIDED_KEY_IDENTIFIER; } + + @Override + @NonNull + public String getCipherAlgorithm(){ + return CIPHER_ALGORITHM; + } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index c66d14087f..8315b20680 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -119,7 +119,12 @@ public enum CommonFlight implements IFlightConfig { /** * Flight to enable exposing the JavaScript API for AuthUx requests */ - ENABLE_JS_API_FOR_AUTHUX("EnableJsApiForAuthUx", true); + ENABLE_JS_API_FOR_AUTHUX("EnableJsApiForAuthUx", true), + + /** + * Flight to enable the new KEK algorithm for encryption/decryption of keys. + */ + ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING("EnableOAEPWithSHAAndMGF1Padding", false); private String key; private Object defaultValue; diff --git a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java index c33e292848..5d0523b095 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java +++ b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java @@ -28,10 +28,12 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import edu.umd.cs.findbugs.annotations.Nullable; import lombok.NonNull; @@ -144,4 +146,43 @@ public static void deleteFile(@NonNull final File file) { } } + + + + + public static String readStringFromFile(File file) { + try (FileInputStream fis = new FileInputStream(file); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + + byte[] buffer = new byte[1024]; + int length; + while ((length = fis.read(buffer)) != -1) { + baos.write(buffer, 0, length); + } + + return baos.toString("UTF-8"); + } catch (IOException e) { + return null; // or handle the exception as needed + } + } + public static void writeStringToFile(String content, File file) throws ClientException { + final String methodTag = TAG + ":writeStringToFile"; + + try (FileOutputStream fos = new FileOutputStream(file)) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + baos.write(content.getBytes(StandardCharsets.UTF_8)); + baos.writeTo(fos); + } catch (IOException e) { + final ClientException clientException = new ClientException( + IO_ERROR, + e.getMessage(), + e + ); + + Logger.error(methodTag, clientException.getErrorCode(), e + ); + throw clientException; + } + } + } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java index 9bdb0aa6d1..3e8d6b9c50 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java @@ -25,6 +25,8 @@ import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; import com.microsoft.identity.common.java.exception.ClientException; +import org.jetbrains.annotations.NotNull; + import javax.crypto.SecretKey; import lombok.NonNull; @@ -37,13 +39,13 @@ public class MockAES256KeyLoader extends AES256KeyLoader { private final String mKeyIdentifier; public MockAES256KeyLoader() throws ClientException { - mKey = generateRandomKey(); + mKey = getSecretKeyGenerator().generateRandomKey(); mKeyIdentifier = DEFAULT_MOCK_KEY_IDENTIFIER; } public MockAES256KeyLoader(@NonNull final byte[] secretKey, @NonNull final String keyIdentifier){ - mKey = generateKeyFromRawBytes(secretKey); + mKey = getSecretKeyGenerator().generateKeyFromRawBytes(secretKey); mKeyIdentifier = keyIdentifier; } @@ -61,4 +63,10 @@ public MockAES256KeyLoader(@NonNull final byte[] secretKey, public @NonNull String getKeyTypeIdentifier() { return mKeyIdentifier; } + + @NotNull + @Override + public String getCipherAlgorithm() { + return ""; + } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java index 3028a5506a..c3d309b7c4 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java @@ -25,6 +25,8 @@ import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; import com.microsoft.identity.common.java.exception.ClientException; +import org.jetbrains.annotations.NotNull; + import javax.crypto.SecretKey; import lombok.NonNull; @@ -48,4 +50,10 @@ public class MockAES256KeyLoaderWithGetKeyError extends AES256KeyLoader { public @NonNull String getKeyTypeIdentifier() { return MOCK_KEY_IDENTIFIER; } + + @NotNull + @Override + public String getCipherAlgorithm() { + return ""; + } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java index 9a6f0e1174..382f992525 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java @@ -22,7 +22,7 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.crypto; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.exception.ClientException; import java.util.ArrayList; @@ -33,21 +33,21 @@ public class MockStorageEncryptionManager extends StorageEncryptionManager { - private final AbstractSecretKeyLoader mEncryptKey; - private final List mDecryptKey; + private final ISecretKeyLoader mEncryptKey; + private final List mDecryptKey; MockStorageEncryptionManager(@NonNull final byte[] iv, - @Nullable final AbstractSecretKeyLoader key) throws ClientException { + @Nullable final ISecretKeyLoader key) throws ClientException { this(iv, key, - new ArrayList() {{ + new ArrayList() {{ add(key); }}); } MockStorageEncryptionManager(@NonNull final byte[] iv, - @Nullable final AbstractSecretKeyLoader encryptKey, - @Nullable final List decryptKey) throws ClientException { + @Nullable final ISecretKeyLoader encryptKey, + @Nullable final List decryptKey) throws ClientException { super(new IVGenerator() { @Override public byte[] generate() { @@ -59,12 +59,12 @@ public byte[] generate() { } @Override - public @NonNull AbstractSecretKeyLoader getKeyLoaderForEncryption() throws ClientException { + public @NonNull ISecretKeyLoader getKeyLoaderForEncryption() throws ClientException { return mEncryptKey; } @Override - public @NonNull List getKeyLoaderForDecryption(@NonNull byte[] cipherText) throws ClientException { + public @NonNull List getKeyLoaderForDecryption(@NonNull byte[] cipherText) throws ClientException { return mDecryptKey; } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java index e89f96b218..3e43c319b8 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java @@ -23,7 +23,7 @@ package com.microsoft.identity.common.java.crypto; import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; @@ -40,7 +40,6 @@ import static com.microsoft.identity.common.java.crypto.MockData.PREDEFINED_KEY_MALFORMED; import static com.microsoft.identity.common.java.crypto.MockData.TEXT_ENCRYPTED_BY_PREDEFINED_KEY; import static com.microsoft.identity.common.java.crypto.MockData.TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY; -import static com.microsoft.identity.common.java.crypto.MockData.EXPECTED_ENCRYPTED_TEXT_1_WITH_MALFORMED_ENCODE_VERSION; import static com.microsoft.identity.common.java.crypto.MockData.PREDEFINED_KEY_IV; import static com.microsoft.identity.common.java.crypto.MockData.ANDROID_WRAPPED_KEY_IV; import static com.microsoft.identity.common.java.crypto.MockData.PREDEFINED_KEY_IDENTIFIER; @@ -88,7 +87,7 @@ public void testDecryptNoKeyLoader() throws ClientException { @Test(expected = RuntimeException.class) public void testDecryptNullKeyLoader() throws ClientException { final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList() {{ + new ArrayList() {{ add(null); }}); manager.decrypt(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); @@ -97,7 +96,7 @@ public void testDecryptNullKeyLoader() throws ClientException { @Test(expected = RuntimeException.class) public void testDecrypt_empty_KeyLoader_throws() throws ClientException { - final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, Collections.emptyList()); + final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, Collections.emptyList()); manager.decrypt(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); Assert.fail("decrypt() should throw an exception but it succeeds."); } @@ -134,7 +133,7 @@ public void testDecryptFailToLoadOneOfTheKeys() throws ClientException { // Key order doesn't matter. final StorageEncryptionManager manager_failFirst = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList(){{ + new ArrayList(){{ add(failingKeyLoader); add(successKeyLoader); }}); @@ -142,7 +141,7 @@ public void testDecryptFailToLoadOneOfTheKeys() throws ClientException { Assert.assertArrayEquals(TEXT_TO_BE_ENCRYPTED_WITH_PREDEFINED_KEY, manager_failFirst.decrypt(TEXT_ENCRYPTED_BY_PREDEFINED_KEY)); final StorageEncryptionManager manager_failSecond = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList(){{ + new ArrayList(){{ add(successKeyLoader); add(failingKeyLoader); }}); @@ -156,7 +155,7 @@ public void testDecryptMatchingKeyNotFound() throws ClientException { final AES256KeyLoader decryptKeyLoader_2 = new MockAES256KeyLoader(); final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList(){{ + new ArrayList(){{ add(decryptKeyLoader); add(decryptKeyLoader_2); }}); From df9555d01a907279d403f5dd5662111a957a7f28 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 11 Jun 2025 17:03:04 -0700 Subject: [PATCH 02/51] add interface ISecretKeyLoader --- .../crypto/AndroidWrappedKeyLoaderTest.java | 8 +- ...ndroidAuthSdkStorageEncryptionManager.java | 13 +- .../crypto/AndroidWrappedKeyLoader.java | 9 +- .../crypto/AndroidWrappedKeyLoaderFactory.kt | 54 +++++++ ...idAuthSdkStorageEncryptionManagerTest.java | 16 +- .../java/crypto/StorageEncryptionManager.java | 22 +-- .../java/crypto/key/AES256KeyLoader.java | 35 ++--- .../crypto/key/AES256SecretKeyGenerator.kt | 93 +++++++++++ .../crypto/key/AbstractSecretKeyLoader.java | 144 ------------------ .../java/crypto/key/ISecretKeyGenerator.kt | 63 ++++++++ .../java/crypto/key/ISecretKeyLoader.kt | 99 ++++++++++++ .../common/java/crypto/key/KeyUtil.java | 4 +- .../java/crypto/key/PredefinedKeyLoader.java | 22 +-- .../common/java/flighting/CommonFlight.java | 5 +- .../java/crypto/MockAES256KeyLoader.java | 5 +- .../crypto/MockStorageEncryptionManager.java | 18 +-- .../crypto/StorageEncryptionManagerTest.java | 13 +- .../key/AES256SecretKeyGeneratorTest.kt | 112 ++++++++++++++ 18 files changed, 501 insertions(+), 234 deletions(-) create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt create mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt delete mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java create mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt create mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt create mode 100644 common4j/src/test/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGeneratorTest.kt diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java index 177e011c89..20f938c79c 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java @@ -113,7 +113,7 @@ public void testGenerateKey() throws ClientException { final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyLoader.generateRandomKey(); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, secretKey.getAlgorithm()); + Assert.assertEquals(keyLoader.getSecretKeyGenerator().getKeyAlgorithm(), secretKey.getAlgorithm()); } @Test @@ -125,8 +125,8 @@ public void testReadKeyDirectly() throws ClientException { // They're not the same object! Assert.assertNotSame(secretKey, storedSecretKey); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, secretKey.getAlgorithm()); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, storedSecretKey.getAlgorithm()); + Assert.assertEquals(keyLoader.getSecretKeyGenerator().getKeyAlgorithm(), secretKey.getAlgorithm()); + Assert.assertEquals(keyLoader.getSecretKeyGenerator().getKeyAlgorithm(), storedSecretKey.getAlgorithm()); Assert.assertNotNull(secretKey.getEncoded()); Assert.assertNotNull(storedSecretKey.getEncoded()); @@ -145,7 +145,7 @@ public void testLoadKey() throws ClientException { final SecretKey key = keyLoader.getKeyCache().getData(); Assert.assertNotNull(key); - Assert.assertEquals(AES256KeyLoader.AES_ALGORITHM, secretKey.getAlgorithm()); + Assert.assertEquals(keyLoader.getSecretKeyGenerator().getKeyAlgorithm(), secretKey.getAlgorithm()); Assert.assertArrayEquals(secretKey.getEncoded(), key.getEncoded()); Assert.assertEquals(secretKey.getFormat(), key.getFormat()); } 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 20e091cbd2..af88960ef8 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 @@ -27,7 +27,7 @@ import com.microsoft.identity.common.adal.internal.AuthenticationSettings; import com.microsoft.identity.common.java.crypto.StorageEncryptionManager; import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.crypto.key.PredefinedKeyLoader; import com.microsoft.identity.common.logging.Logger; @@ -65,10 +65,11 @@ public AndroidAuthSdkStorageEncryptionManager(@NonNull final Context context) { AuthenticationSettings.INSTANCE.getSecretKeyData()); } - mKeyStoreKeyLoader = new AndroidWrappedKeyLoader( + mKeyStoreKeyLoader = AndroidWrappedKeyLoaderFactory.INSTANCE.createWrappedKeyLoader( WRAPPING_KEY_ALIAS, WRAPPED_KEY_FILE_NAME, - context); + context + ); } @Override @@ -83,20 +84,20 @@ public AES256KeyLoader getKeyLoaderForEncryption() { @Override @NonNull - public List getKeyLoaderForDecryption(byte[] cipherText) { + public List getKeyLoaderForDecryption(byte[] cipherText) { final String methodTag = TAG + ":getKeyLoaderForDecryption"; final String keyIdentifier = getKeyIdentifierFromCipherText(cipherText); if (PredefinedKeyLoader.USER_PROVIDED_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) { if (mPredefinedKeyLoader != null) { - return Collections.singletonList(mPredefinedKeyLoader); + return Collections.singletonList(mPredefinedKeyLoader); } else { throw new IllegalStateException( "Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, " + "but mPredefinedKeyLoader is null."); } } else if (AndroidWrappedKeyLoader.WRAPPED_KEY_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) { - return Collections.singletonList(mKeyStoreKeyLoader); + return Collections.singletonList(mKeyStoreKeyLoader); } Logger.warn(methodTag, diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java index 6e4495fdbd..c0f857930e 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java @@ -177,12 +177,11 @@ public synchronized SecretKey getKey() throws ClientException { return key; } - @Override @NonNull protected SecretKey generateRandomKey() throws ClientException { final String methodTag = TAG + ":generateRandomKey"; - final SecretKey key = super.generateRandomKey(); + final SecretKey key = getSecretKeyGenerator().generateRandomKey(); saveSecretKeyToStorage(key); Logger.info(methodTag, "New key is generated with thumbprint: " + @@ -217,7 +216,7 @@ protected SecretKey generateRandomKey() throws ClientException { return null; } - final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, getKeySpecAlgorithm(), keyPair, WRAP_ALGORITHM); + final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, getSecretKeyGenerator().getKeyAlgorithm(), keyPair, WRAP_ALGORITHM); Logger.info(methodTag, "Key is loaded with thumbprint: " + KeyUtil.getKeyThumbPrint(key)); @@ -259,7 +258,7 @@ private void saveSecretKeyToStorage(@NonNull final SecretKey unencryptedKey) thr if (keyPair == null) { Logger.info(methodTag, "No existing keypair. Generating a new one."); final Span span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name(), SpanExtension.current().getSpanContext()); - try (final Scope scope = SpanExtension.makeCurrentSpan(span)) { + try (final Scope ignored = SpanExtension.makeCurrentSpan(span)) { keyPair = generateNewKeyPair(); span.setStatus(StatusCode.OK); } catch (final ClientException e) { @@ -329,7 +328,7 @@ private KeyPair generateNewKeyPairAPI23AndAbove() throws ClientException { * Generate a new key pair wrapping key based on legacy logic. Call this for API < 23 or as fallback * until new key gen specs are stable. * @return key pair generated with legacy spec - * @throws ClientException + * @throws ClientException if there is an error generating the key pair */ @NonNull private KeyPair generateKeyPairWithLegacySpec() throws ClientException{ diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt new file mode 100644 index 0000000000..61b10bb6b9 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import com.microsoft.identity.common.java.flighting.CommonFlight +import com.microsoft.identity.common.java.flighting.CommonFlightsManager + +object AndroidWrappedKeyLoaderFactory { + fun createWrappedKeyLoader( + keyIdentifier: String, + fileName: String, + context: android.content.Context + ): AndroidWrappedKeyLoader { + val useNewAndroidWrappedKeyLoader = + CommonFlightsManager + .getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER) + + return if (useNewAndroidWrappedKeyLoader) { + // TODO : Replace with the new loader on the next PR + AndroidWrappedKeyLoader( + keyIdentifier, + fileName, + context + ) + } else { + AndroidWrappedKeyLoader( + keyIdentifier, + fileName, + context + ) + } + } +} \ No newline at end of file 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 f1e0c4510b..73a508df3e 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 @@ -32,7 +32,7 @@ import androidx.test.core.app.ApplicationProvider; import com.microsoft.identity.common.adal.internal.AuthenticationSettings; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.crypto.key.KeyUtil; import com.microsoft.identity.common.java.crypto.key.PredefinedKeyLoader; @@ -62,7 +62,7 @@ public void setUp() { public void testGetEncryptionKey() { final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final AbstractSecretKeyLoader loader = manager.getKeyLoaderForEncryption(); + final ISecretKeyLoader loader = manager.getKeyLoaderForEncryption(); Assert.assertTrue(loader instanceof AndroidWrappedKeyLoader); Assert.assertNotEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(loader)); } @@ -72,7 +72,7 @@ public void testGetEncryptionKey_PreDefinedKeyProvided() { AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final AbstractSecretKeyLoader loader = manager.getKeyLoaderForEncryption(); + final ISecretKeyLoader loader = manager.getKeyLoaderForEncryption(); Assert.assertTrue(loader instanceof PredefinedKeyLoader); Assert.assertEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(loader)); } @@ -84,7 +84,7 @@ public void testGetEncryptionKey_PreDefinedKeyProvided() { @Test public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() { final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); Assert.assertEquals(1, keyLoaderList.size()); Assert.assertTrue(keyLoaderList.get(0) instanceof AndroidWrappedKeyLoader); @@ -99,7 +99,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() { public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyProvided() { AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); Assert.assertEquals(1, keyLoaderList.size()); Assert.assertTrue(keyLoaderList.get(0) instanceof AndroidWrappedKeyLoader); @@ -114,7 +114,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyPr public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey() { final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); try { - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); } catch (IllegalStateException ex) { Assert.assertEquals( "Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, but mPredefinedKeyLoader is null.", @@ -125,7 +125,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey() { public void testGetDecryptionKey_ForUnencryptedText_returns_empty_keyloader() { AuthenticationSettings.INSTANCE.setIgnoreKeyLoaderNotFoundError(false); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption("Unencrypted".getBytes(ENCODING_UTF8)); + final List keyLoaderList = manager.getKeyLoaderForDecryption("Unencrypted".getBytes(ENCODING_UTF8)); Assert.assertEquals(0, keyLoaderList.size()); } @@ -137,7 +137,7 @@ public void testGetDecryptionKey_ForUnencryptedText_returns_empty_keyloader() { public void testGetDecryptionKey_ForDataEncryptedWithPreDefinedKey_PreDefinedKeyProvided() { AuthenticationSettings.INSTANCE.setSecretKey(PREDEFINED_KEY); final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); - final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); + final List keyLoaderList = manager.getKeyLoaderForDecryption(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); Assert.assertEquals(1, keyLoaderList.size()); Assert.assertTrue(keyLoaderList.get(0) instanceof PredefinedKeyLoader); diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java index cd7fcc5dc2..410b02c3c2 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java @@ -38,7 +38,7 @@ import com.microsoft.identity.common.java.base64.Base64Flags; import com.microsoft.identity.common.java.base64.Base64Util; import com.microsoft.identity.common.java.controllers.ExceptionAdapter; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.crypto.key.KeyUtil; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; @@ -128,7 +128,7 @@ public byte[] encrypt(final byte[] plaintext) final Throwable exception; // load key for encryption if not loaded - final AbstractSecretKeyLoader keyLoader = getKeyLoaderForEncryption(); + final ISecretKeyLoader keyLoader = getKeyLoaderForEncryption(); if (keyLoader == null) { // Developer error. Throw. throw new IllegalStateException("Cannot find a matching Keyloader."); @@ -144,7 +144,7 @@ public byte[] encrypt(final byte[] plaintext) final IvParameterSpec ivSpec = new IvParameterSpec(iv); // Set to encrypt mode - final Cipher cipher = Cipher.getInstance(keyLoader.getCipherAlgorithm()); + final Cipher cipher = Cipher.getInstance(keyLoader.getCipherTransformation()); final Mac mac = Mac.getInstance(HMAC_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, ivSpec); @@ -212,14 +212,14 @@ public byte[] decrypt(final byte[] cipherText) throws ClientException { return cipherText; } - final List keysForDecryption = getKeyLoaderForDecryption(cipherText); + final List keysForDecryption = getKeyLoaderForDecryption(cipherText); if (keysForDecryption.size() == 0) { // Developer error. Throw. throw new IllegalStateException("Cannot find a matching Keyloader."); } final List suppressedException = new ArrayList<>(); - for (final AbstractSecretKeyLoader keyLoader : keysForDecryption) { + for (final ISecretKeyLoader keyLoader : keysForDecryption) { try { return decryptWithSecretKey(dataBytes, keyLoader); } catch (final Throwable e) { @@ -271,14 +271,14 @@ public IKeyAccessor generateDerivedKey(byte[] label, byte[] ctx, CryptoSuite sui } /** - * Decrypted the given encrypted blob with a key from {@link AbstractSecretKeyLoader} + * Decrypted the given encrypted blob with a key from {@link ISecretKeyLoader} * * @param encryptedBlobWithoutEncodeVersion the encrypted blob with the format of * [KeyIdentifier][encryptedData][iv][MACDigest]. - * @param keyLoader a {@link AbstractSecretKeyLoader} to load the decryption key from. + * @param keyLoader a {@link ISecretKeyLoader} to load the decryption key from. */ private byte[] decryptWithSecretKey(final byte[] encryptedBlobWithoutEncodeVersion, - @NonNull final AbstractSecretKeyLoader keyLoader) + @NonNull final ISecretKeyLoader keyLoader) throws ClientException { final String errCode; final Throwable exception; @@ -295,7 +295,7 @@ private byte[] decryptWithSecretKey(final byte[] encryptedBlobWithoutEncodeVersi // Calculate digest again and compare to the appended value // incoming message: version+encryptedData+IV+Digest // Digest of EncryptedData+IV excluding the digest itself. - final Cipher cipher = Cipher.getInstance(keyLoader.getCipherAlgorithm()); + final Cipher cipher = Cipher.getInstance(keyLoader.getCipherTransformation()); final Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(hmacKey); mac.update(encryptedBlobWithoutEncodeVersion, 0, macDigestIndex); @@ -492,7 +492,7 @@ private void assertHMac(final byte[] encryptedBlob, * @return a SecretKey loader. */ @NonNull - public abstract AbstractSecretKeyLoader getKeyLoaderForEncryption() throws ClientException; + public abstract ISecretKeyLoader getKeyLoaderForEncryption() throws ClientException; /** * Identify the encrypted blob and return a list of potential candidate key loaders for decryption. @@ -501,5 +501,5 @@ private void assertHMac(final byte[] encryptedBlob, * @return a prioritized list of SecretKey (earlier keys is more likely to be the correct one). **/ @NonNull - abstract public List getKeyLoaderForDecryption(final byte[] cipherText) throws ClientException; + abstract public List getKeyLoaderForDecryption(final byte[] cipherText) throws ClientException; } diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java index a6e0cc4b4e..f3dfbde8fb 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java @@ -22,42 +22,25 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.crypto.key; -import lombok.NonNull; - -public abstract class AES256KeyLoader extends AbstractSecretKeyLoader { - private static final String TAG = AES256KeyLoader.class.getSimpleName(); - - /** - * Key size - */ - private static final int KEY_SIZE = 256; - - /** - * Key spec algorithm. - */ - public static final String AES_ALGORITHM = "AES"; +import org.jetbrains.annotations.NotNull; +public abstract class AES256KeyLoader implements ISecretKeyLoader { /** * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here * probably doing PKCS7. We decide to go with Java default string. */ - private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; - - @Override - @NonNull - public String getKeySpecAlgorithm() { - return AES_ALGORITHM; - } + private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; + @NotNull @Override - @NonNull - public String getCipherAlgorithm(){ - return CIPHER_ALGORITHM; + public String getCipherTransformation() { + return CIPHER_TRANSFORMATION; } @Override - protected int getKeySize() { - return KEY_SIZE; + @NotNull + public AES256SecretKeyGenerator getSecretKeyGenerator() { + return new AES256SecretKeyGenerator(); } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt new file mode 100644 index 0000000000..f157f531b8 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.crypto.key + +import ISecretKeyGenerator +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.logging.Logger +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * Implementation of [ISecretKeyGenerator] for AES-256 keys. + * This class provides functionality to generate random AES-256 secret keys or + * create them from raw byte arrays. + */ +class AES256SecretKeyGenerator : ISecretKeyGenerator { + + companion object { + private val TAG = AES256SecretKeyGenerator::class.java.simpleName + } + + /** + * Returns the key size in bits (256 for AES-256). + * @return Key size as an integer value. + */ + override val keySize: Int + get() = 256 + + /** + * Returns the algorithm name for the key specification. + * @return String representation of the algorithm name ("AES"). + */ + override val keyAlgorithm: String + get() = "AES" + + /** + * Generates a random AES-256 secret key. + * Uses [KeyGenerator] to create a cryptographically secure random key. + * + * @return A randomly generated [SecretKey] instance. + * @throws ClientException If the algorithm is not available on the current platform. + */ + @Throws(ClientException::class) + override fun generateRandomKey(): SecretKey { + val methodTag = "$TAG:generateRandomKey" + try { + val keygen = KeyGenerator.getInstance(keyAlgorithm) + keygen.init(keySize, SecureRandom()) + return keygen.generateKey() + } catch (e: NoSuchAlgorithmException) { + val clientException = ClientException( + ClientException.NO_SUCH_ALGORITHM, + e.message, + e + ) + Logger.error(methodTag, clientException.errorCode, e) + throw clientException + } + } + + /** + * Creates an AES-256 secret key from the provided raw bytes. + * + * @param rawBytes The raw byte array to create the key from. + * @return A [SecretKey] created from the provided raw bytes. + */ + override fun generateKeyFromRawBytes(rawBytes: ByteArray): SecretKey { + return SecretKeySpec(rawBytes, keyAlgorithm) + } +} \ No newline at end of file diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java deleted file mode 100644 index c9555c2f2d..0000000000 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AbstractSecretKeyLoader.java +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.java.crypto.key; - -import com.microsoft.identity.common.java.base64.Base64Flags; -import com.microsoft.identity.common.java.base64.Base64Util; -import com.microsoft.identity.common.java.crypto.StorageEncryptionManager; -import com.microsoft.identity.common.java.exception.ClientException; -import com.microsoft.identity.common.java.logging.Logger; - -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; - -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; - -import lombok.NonNull; - -import static com.microsoft.identity.common.java.exception.ClientException.NO_SUCH_ALGORITHM; - -/** - * Abstracts how a {@link SecretKey} is loaded/cached/sourced/used. - */ -public abstract class AbstractSecretKeyLoader { - private static final String TAG = AbstractSecretKeyLoader.class.getSimpleName(); - - /** - * Returns this key's alias/name. - * Each key will have a unique alias/name. - */ - @NonNull - public abstract String getAlias(); - - /** - * Returns the key. - */ - @NonNull - public abstract SecretKey getKey() throws ClientException; - - /** - * Returns the Algorithm of this key. - * This must be compatible with {@link KeyGenerator#getInstance(String)} - */ - @NonNull - protected abstract String getKeySpecAlgorithm(); - - /** - * Returns the size of this key. - * This must be compatible with {@link KeyGenerator#init(int, SecureRandom)} )} - */ - protected abstract int getKeySize(); - - /** - * Gets an identifier of this key type. - * This might be padded into the encrypted string. - */ - @NonNull - public abstract String getKeyTypeIdentifier(); - - /** - * Gets the cipher algorithm that is meant to be used with this key type. - */ - @NonNull - public abstract String getCipherAlgorithm(); - - /** - * Generate a random AES-256 secret key. - * - * @return SecretKey. - */ - @NonNull - protected SecretKey generateRandomKey() throws ClientException { - final String methodName = ":generateRandomKey"; - - try { - final KeyGenerator keygen = KeyGenerator.getInstance(getKeySpecAlgorithm()); - keygen.init(getKeySize(), new SecureRandom()); - return keygen.generateKey(); - } catch (final NoSuchAlgorithmException e) { - final ClientException clientException = new ClientException( - NO_SUCH_ALGORITHM, - e.getMessage(), - e - ); - - Logger.error( - TAG + methodName, - clientException.getErrorCode(), - e - ); - - throw clientException; - } - } - - /** - * Generate a random AES-256 secret key from rawbytes. - *

- * If a non AES-256 rawBytes is provided, this will still return a SecretKey, - * but an exception would be thrown in {@link StorageEncryptionManager} - * during encryption/decryption. - * - * @return SecretKey. - */ - @NonNull - protected SecretKey generateKeyFromRawBytes(@NonNull final byte[] rawBytes) { - return new SecretKeySpec(rawBytes, getKeySpecAlgorithm()); - } - - /** - * Serializes a {@link SecretKey} into a {@link String}. - */ - public String serializeSecretKey(@NonNull final SecretKey key) { - return Base64Util.encodeToString(key.getEncoded(), Base64Flags.DEFAULT); - } - - /** - * Deserializes a {@link String} into a {@link SecretKey}. - */ - public SecretKey deserializeSecretKey(@NonNull final String serializedKey) { - return generateKeyFromRawBytes(Base64Util.decode(serializedKey, Base64Flags.DEFAULT)); - } -} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt new file mode 100644 index 0000000000..ba6ad42403 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE.package com.microsoft.identity.common.java.crypto.key + +import com.microsoft.identity.common.java.exception.ClientException +import javax.crypto.SecretKey + +/** + * Interface for secret key generation. + * Implementations of this interface provide functionality to generate cryptographic + * secret keys either randomly or from raw byte arrays. + */ +interface ISecretKeyGenerator { + /** + * The size of the key in bits. + * This is used when generating random keys to determine the key strength. + */ + val keySize: Int + + /** + * The algorithm name for the key. + * This should be compatible with cryptographic providers, such as + * those used with KeyGenerator.getInstance(algorithm). + */ + val keyAlgorithm: String + + /** + * Generates a cryptographically secure random secret key. + * + * @return A randomly generated [SecretKey] instance. + * @throws ClientException If an error occurs during key generation, + * such as when the algorithm is not available. + */ + @Throws(ClientException::class) + fun generateRandomKey(): SecretKey + + /** + * Creates a secret key from the provided raw bytes. + * + * @param rawBytes The raw byte array to create the key from. + * @return A [SecretKey] created from the provided raw bytes. + */ + fun generateKeyFromRawBytes(rawBytes: ByteArray): SecretKey +} \ No newline at end of file diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt new file mode 100644 index 0000000000..ebb3bd9ce6 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.crypto.key + +import ISecretKeyGenerator +import com.microsoft.identity.common.java.exception.ClientException +import javax.crypto.SecretKey + +/** + * Interface defining how a [SecretKey] is loaded, cached, sourced, and used. + * + * [ISecretKeyLoader] provides a consistent abstraction layer for cryptographic key operations + */ +interface ISecretKeyLoader { + /** + * Returns this key's alias or name. + * + * The alias serves as a unique identifier for this key within the system. + * It can be used for key storage, retrieval, and reference across the application. + * Each key implementation must have a unique alias to avoid collisions. + * + * @return The unique key alias as a non-null String. + */ + val alias: String + + /** + * Gets an identifier of this key type. + * + * The key type identifier is used to distinguish between different key types + * in the system. This value might be padded into encrypted strings to indicate + * the key used for encryption, enabling correct key selection during decryption. + * + * @return The key type identifier as a non-null String. + */ + val keyTypeIdentifier: String + + /** + * Gets the cipher transformation string that is meant to be used with this key type. + * + * A cipher transformation string consists of three components: + * - Algorithm: The base cryptographic algorithm (e.g., "AES", "RSA") + * - Mode of operation: How the algorithm should process the data (e.g., "CBC", "GCM", "ECB") + * - Padding scheme: How to handle data that doesn't align with the block size (e.g., "PKCS5Padding", "NoPadding") + * + * For example, "AES/CBC/PKCS5Padding" specifies the AES algorithm in CBC mode with PKCS5 padding. + * The transformation specified must be compatible with the generated keys and supported by the + * security provider being used. + * + * This transformation string is used directly with [javax.crypto.Cipher.getInstance] to create + * the appropriate Cipher object for encryption and decryption operations. + * + * @return The complete cipher transformation string as a non-null String. + */ + val cipherTransformation: String + + /** + * The [ISecretKeyGenerator] used to generate new keys when needed. + * + * This generator is responsible for creating cryptographically secure keys + * using the appropriate algorithm and key size for this key loader implementation. + * + * @return The secret key generator for this key type. + */ + val secretKeyGenerator: ISecretKeyGenerator + + /** + * Retrieves the secret key for encryption/decryption operations. + * + * This method handles the loading of an existing key or generation of a new key + * if one doesn't exist. Key storage, caching, and platform-specific logic + * should be encapsulated within implementations of this property. + * + * @return The [SecretKey] to be used for cryptographic operations. + * @throws ClientException If an error occurs during key retrieval or generation, + * including key store access issues or algorithm unavailability. + */ + @get:Throws(ClientException::class) + val key: SecretKey +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java index 4219d64c88..235c770d73 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/KeyUtil.java @@ -64,12 +64,12 @@ public class KeyUtil { private static final String HMAC_KEYSPEC_ALGORITHM = "AES"; /** - * Derive a thumbprint from the given {@link AbstractSecretKeyLoader}. + * Derive a thumbprint from the given {@link ISecretKeyLoader}. * * @param keyLoader ISecretKeyLoader to obtain the key (calculate the thumbprint from). * @return a thumbprint. Will return {@link KeyUtil#UNKNOWN_THUMBPRINT} if it fails to derived one. */ - public static String getKeyThumbPrint(final @NonNull AbstractSecretKeyLoader keyLoader) { + public static String getKeyThumbPrint(final @NonNull ISecretKeyLoader keyLoader) { final String methodName = ":getKeyThumbPrint"; try { return getKeyThumbPrint(keyLoader.getKey()); diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java index 25f12832b7..4480c3d79a 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java @@ -22,6 +22,10 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.crypto.key; +import com.microsoft.identity.common.java.exception.ClientException; + +import org.jetbrains.annotations.NotNull; + import javax.crypto.SecretKey; import lombok.NonNull; @@ -40,26 +44,26 @@ public class PredefinedKeyLoader extends AES256KeyLoader { private final SecretKey mKey; public PredefinedKeyLoader(@NonNull final String alias, - @NonNull final byte[] rawBytes) { + final byte[] rawBytes) { mAlias = alias; - mKey = generateKeyFromRawBytes(rawBytes); + mKey = getSecretKeyGenerator().generateKeyFromRawBytes(rawBytes); } + @NotNull @Override - @NonNull public String getAlias() { return mAlias; } + @NotNull @Override - @NonNull - public SecretKey getKey() { - return mKey; + public String getKeyTypeIdentifier() { + return USER_PROVIDED_KEY_IDENTIFIER; } + @NotNull @Override - @NonNull - public String getKeyTypeIdentifier() { - return USER_PROVIDED_KEY_IDENTIFIER; + public SecretKey getKey() throws ClientException { + return mKey; } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index c66d14087f..4424ecb7e6 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -119,7 +119,10 @@ public enum CommonFlight implements IFlightConfig { /** * Flight to enable exposing the JavaScript API for AuthUx requests */ - ENABLE_JS_API_FOR_AUTHUX("EnableJsApiForAuthUx", true); + ENABLE_JS_API_FOR_AUTHUX("EnableJsApiForAuthUx", true), + + + ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER("EnableNewAndroidWrappedKeyLoader", true); private String key; private Object defaultValue; diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java index 9bdb0aa6d1..0cb36d3b04 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java @@ -25,6 +25,7 @@ import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; import com.microsoft.identity.common.java.exception.ClientException; + import javax.crypto.SecretKey; import lombok.NonNull; @@ -37,13 +38,13 @@ public class MockAES256KeyLoader extends AES256KeyLoader { private final String mKeyIdentifier; public MockAES256KeyLoader() throws ClientException { - mKey = generateRandomKey(); + mKey = getSecretKeyGenerator().generateRandomKey(); mKeyIdentifier = DEFAULT_MOCK_KEY_IDENTIFIER; } public MockAES256KeyLoader(@NonNull final byte[] secretKey, @NonNull final String keyIdentifier){ - mKey = generateKeyFromRawBytes(secretKey); + mKey = getSecretKeyGenerator().generateKeyFromRawBytes(secretKey); mKeyIdentifier = keyIdentifier; } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java index 9a6f0e1174..382f992525 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockStorageEncryptionManager.java @@ -22,7 +22,7 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.crypto; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.exception.ClientException; import java.util.ArrayList; @@ -33,21 +33,21 @@ public class MockStorageEncryptionManager extends StorageEncryptionManager { - private final AbstractSecretKeyLoader mEncryptKey; - private final List mDecryptKey; + private final ISecretKeyLoader mEncryptKey; + private final List mDecryptKey; MockStorageEncryptionManager(@NonNull final byte[] iv, - @Nullable final AbstractSecretKeyLoader key) throws ClientException { + @Nullable final ISecretKeyLoader key) throws ClientException { this(iv, key, - new ArrayList() {{ + new ArrayList() {{ add(key); }}); } MockStorageEncryptionManager(@NonNull final byte[] iv, - @Nullable final AbstractSecretKeyLoader encryptKey, - @Nullable final List decryptKey) throws ClientException { + @Nullable final ISecretKeyLoader encryptKey, + @Nullable final List decryptKey) throws ClientException { super(new IVGenerator() { @Override public byte[] generate() { @@ -59,12 +59,12 @@ public byte[] generate() { } @Override - public @NonNull AbstractSecretKeyLoader getKeyLoaderForEncryption() throws ClientException { + public @NonNull ISecretKeyLoader getKeyLoaderForEncryption() throws ClientException { return mEncryptKey; } @Override - public @NonNull List getKeyLoaderForDecryption(@NonNull byte[] cipherText) throws ClientException { + public @NonNull List getKeyLoaderForDecryption(@NonNull byte[] cipherText) throws ClientException { return mDecryptKey; } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java index e89f96b218..3e43c319b8 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/StorageEncryptionManagerTest.java @@ -23,7 +23,7 @@ package com.microsoft.identity.common.java.crypto; import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; -import com.microsoft.identity.common.java.crypto.key.AbstractSecretKeyLoader; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.exception.ErrorStrings; @@ -40,7 +40,6 @@ import static com.microsoft.identity.common.java.crypto.MockData.PREDEFINED_KEY_MALFORMED; import static com.microsoft.identity.common.java.crypto.MockData.TEXT_ENCRYPTED_BY_PREDEFINED_KEY; import static com.microsoft.identity.common.java.crypto.MockData.TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY; -import static com.microsoft.identity.common.java.crypto.MockData.EXPECTED_ENCRYPTED_TEXT_1_WITH_MALFORMED_ENCODE_VERSION; import static com.microsoft.identity.common.java.crypto.MockData.PREDEFINED_KEY_IV; import static com.microsoft.identity.common.java.crypto.MockData.ANDROID_WRAPPED_KEY_IV; import static com.microsoft.identity.common.java.crypto.MockData.PREDEFINED_KEY_IDENTIFIER; @@ -88,7 +87,7 @@ public void testDecryptNoKeyLoader() throws ClientException { @Test(expected = RuntimeException.class) public void testDecryptNullKeyLoader() throws ClientException { final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList() {{ + new ArrayList() {{ add(null); }}); manager.decrypt(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); @@ -97,7 +96,7 @@ public void testDecryptNullKeyLoader() throws ClientException { @Test(expected = RuntimeException.class) public void testDecrypt_empty_KeyLoader_throws() throws ClientException { - final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, Collections.emptyList()); + final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, Collections.emptyList()); manager.decrypt(TEXT_ENCRYPTED_BY_PREDEFINED_KEY); Assert.fail("decrypt() should throw an exception but it succeeds."); } @@ -134,7 +133,7 @@ public void testDecryptFailToLoadOneOfTheKeys() throws ClientException { // Key order doesn't matter. final StorageEncryptionManager manager_failFirst = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList(){{ + new ArrayList(){{ add(failingKeyLoader); add(successKeyLoader); }}); @@ -142,7 +141,7 @@ public void testDecryptFailToLoadOneOfTheKeys() throws ClientException { Assert.assertArrayEquals(TEXT_TO_BE_ENCRYPTED_WITH_PREDEFINED_KEY, manager_failFirst.decrypt(TEXT_ENCRYPTED_BY_PREDEFINED_KEY)); final StorageEncryptionManager manager_failSecond = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList(){{ + new ArrayList(){{ add(successKeyLoader); add(failingKeyLoader); }}); @@ -156,7 +155,7 @@ public void testDecryptMatchingKeyNotFound() throws ClientException { final AES256KeyLoader decryptKeyLoader_2 = new MockAES256KeyLoader(); final StorageEncryptionManager manager = new MockStorageEncryptionManager(PREDEFINED_KEY_IV, null, - new ArrayList(){{ + new ArrayList(){{ add(decryptKeyLoader); add(decryptKeyLoader_2); }}); diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGeneratorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGeneratorTest.kt new file mode 100644 index 0000000000..8e340117ff --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGeneratorTest.kt @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.crypto.key + +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.MockitoAnnotations +import javax.crypto.spec.SecretKeySpec + +/** + * Unit tests for [AES256SecretKeyGenerator]. + */ +class AES256SecretKeyGeneratorTest { + + private lateinit var secretKeyGenerator: AES256SecretKeyGenerator + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + secretKeyGenerator = AES256SecretKeyGenerator() + } + + @Test + fun testKeySize() { + // Verify that the key size is 256 bits for AES-256 + Assert.assertEquals(256, secretKeyGenerator.keySize) + } + + @Test + fun testKeyAlgorithm() { + // Verify that the key algorithm is "AES" + Assert.assertEquals("AES", secretKeyGenerator.keyAlgorithm) + } + + @Test + fun testGenerateRandomKey() { + // Generate a random key + val secretKey = secretKeyGenerator.generateRandomKey() + + // Verify that the key is not null + Assert.assertNotNull(secretKey) + + // Verify that the key algorithm is "AES" + Assert.assertEquals("AES", secretKey.algorithm) + + // Verify that the encoded form of the key has the correct length (32 bytes for 256 bits) + Assert.assertEquals(32, secretKey.encoded.size) + } + + @Test + fun testGenerateKeyFromRawBytes() { + // Create a byte array of 32 bytes (256 bits) filled with a test value + val rawBytes = ByteArray(32) { 0x42.toByte() } + + // Generate a key from the raw bytes + val secretKey = secretKeyGenerator.generateKeyFromRawBytes(rawBytes) + + // Verify that the key is not null + Assert.assertNotNull(secretKey) + + // Verify that the key algorithm is "AES" + Assert.assertEquals("AES", secretKey.algorithm) + + // Verify that the encoded form of the key matches the input raw bytes + Assert.assertArrayEquals(rawBytes, secretKey.encoded) + } + + @Test + fun testGenerateKeyFromRawBytes_VerifyInstance() { + // Create a byte array of 32 bytes (256 bits) + val rawBytes = ByteArray(32) { 0x37.toByte() } + + // Generate a key from the raw bytes + val secretKey = secretKeyGenerator.generateKeyFromRawBytes(rawBytes) + + // Verify that the key is an instance of SecretKeySpec + Assert.assertTrue(secretKey is SecretKeySpec) + } + + @Test + fun testGenerateMultipleRandomKeys_AreUnique() { + // Generate two random keys + val secretKey1 = secretKeyGenerator.generateRandomKey() + val secretKey2 = secretKeyGenerator.generateRandomKey() + + // Verify that the two keys are different + // Note: There is a very small probability that two randomly generated keys could be identical, + // but this is extremely unlikely and would indicate an issue with the random number generator + Assert.assertFalse(secretKey1.encoded.contentEquals(secretKey2.encoded)) + } +} From 0b256af2265b430124cd674d1e11f23282ea101a Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 11 Jun 2025 20:47:47 -0700 Subject: [PATCH 03/51] nits --- .../crypto/AndroidWrappedKeyLoader.java | 15 +++++++++++ .../crypto/AndroidWrappedKeyLoaderFactory.kt | 25 ++++++++++++++++-- .../java/crypto/key/AES256KeyLoader.java | 26 +++++++++++-------- .../crypto/key/AES256SecretKeyGenerator.kt | 2 +- .../java/crypto/key/ISecretKeyGenerator.kt | 2 +- .../java/crypto/key/PredefinedKeyLoader.java | 12 +++++++++ .../java/crypto/MockAES256KeyLoader.java | 10 ++++++- .../MockAES256KeyLoaderWithGetKeyError.java | 8 ++++++ 8 files changed, 84 insertions(+), 16 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java index c0f857930e..47083b6885 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java @@ -72,6 +72,14 @@ * before it get saved to the file. */ public class AndroidWrappedKeyLoader extends AES256KeyLoader { + + /** + * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in + * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here + * probably doing PKCS7. We decide to go with Java default string. + */ + private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; + private static final String TAG = AndroidWrappedKeyLoader.class.getSimpleName() + "#"; /** @@ -463,4 +471,11 @@ private File getKeyFile() { mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), mFilePath); } + + @NonNull + @Override + public String getCipherTransformation() { + return CIPHER_TRANSFORMATION; + + } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt index 61b10bb6b9..3c64fc5828 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt @@ -22,15 +22,36 @@ // THE SOFTWARE. package com.microsoft.identity.common.crypto +import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager +/** + * Factory class for creating wrapped key loaders specific to the Android platform. + * + * This object is responsible for creating the appropriate implementation of [ISecretKeyLoader] + * based on feature flag. It abstracts away the details of which + * specific loader implementation should be used, allowing for runtime switching between + * different implementations without affecting client code. + */ object AndroidWrappedKeyLoaderFactory { + /** + * Creates an appropriate wrapped key loader instance based on current feature flags. + * + * This method checks the [CommonFlight.ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER] feature flag + * to determine whether to use the new implementation or the legacy implementation of + * the Android wrapped key loader. + * + * @param keyIdentifier A unique identifier for the key being loaded + * @param fileName The name of the file where the wrapped key is stored + * @param context The Android application context needed for file and security operations + * @return An implementation of [ISecretKeyLoader] that can load the specified wrapped key + */ fun createWrappedKeyLoader( keyIdentifier: String, fileName: String, context: android.content.Context - ): AndroidWrappedKeyLoader { + ): ISecretKeyLoader { val useNewAndroidWrappedKeyLoader = CommonFlightsManager .getFlightsProvider() @@ -51,4 +72,4 @@ object AndroidWrappedKeyLoaderFactory { ) } } -} \ No newline at end of file +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java index f3dfbde8fb..77c26f6477 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java @@ -24,20 +24,24 @@ import org.jetbrains.annotations.NotNull; +/** + * Abstract base class for loading AES-256 secret keys. + *

+ * This class implements the {@link ISecretKeyLoader} interface and provides a default + * implementation for retrieving an AES-256 key generator. It serves as a base class + * for concrete implementations that handle the loading of AES-256 secret keys from + * various sources. + */ public abstract class AES256KeyLoader implements ISecretKeyLoader { + /** - * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in - * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here - * probably doing PKCS7. We decide to go with Java default string. + * Returns an AES-256 secret key generator. + *

+ * This implementation creates a new instance of {@link AES256SecretKeyGenerator} + * which can be used to generate new AES-256 keys when needed. + * + * @return A new instance of {@link AES256SecretKeyGenerator} */ - private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; - - @NotNull - @Override - public String getCipherTransformation() { - return CIPHER_TRANSFORMATION; - } - @Override @NotNull public AES256SecretKeyGenerator getSecretKeyGenerator() { diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt index f157f531b8..e798d4831c 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt @@ -90,4 +90,4 @@ class AES256SecretKeyGenerator : ISecretKeyGenerator { override fun generateKeyFromRawBytes(rawBytes: ByteArray): SecretKey { return SecretKeySpec(rawBytes, keyAlgorithm) } -} \ No newline at end of file +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt index ba6ad42403..1b08f41483 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt @@ -60,4 +60,4 @@ interface ISecretKeyGenerator { * @return A [SecretKey] created from the provided raw bytes. */ fun generateKeyFromRawBytes(rawBytes: ByteArray): SecretKey -} \ No newline at end of file +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java index 4480c3d79a..694d9a1376 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyLoader.java @@ -34,6 +34,12 @@ * For loading an AES-256 key from a provided rawbytes array. */ public class PredefinedKeyLoader extends AES256KeyLoader { + /** + * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in + * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here + * probably doing PKCS7. We decide to go with Java default string. + */ + private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; /** * Indicate that the token item is encrypted with the user provided key. @@ -66,4 +72,10 @@ public String getKeyTypeIdentifier() { public SecretKey getKey() throws ClientException { return mKey; } + + @NotNull + @Override + public String getCipherTransformation() { + return CIPHER_TRANSFORMATION; + } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java index 0cb36d3b04..bf6596d306 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java @@ -26,6 +26,8 @@ import com.microsoft.identity.common.java.exception.ClientException; +import org.jetbrains.annotations.NotNull; + import javax.crypto.SecretKey; import lombok.NonNull; @@ -33,7 +35,7 @@ public class MockAES256KeyLoader extends AES256KeyLoader { public static String DEFAULT_MOCK_KEY_IDENTIFIER = "MOCK_ID"; public static String MOCK_ALIAS = "MOCK_ALIAS"; - + public static String MOCK_CIPHER_TRANSFORMATION = "MOCK_CIPHER_TRANSFORMATION"; private final SecretKey mKey; private final String mKeyIdentifier; @@ -62,4 +64,10 @@ public MockAES256KeyLoader(@NonNull final byte[] secretKey, public @NonNull String getKeyTypeIdentifier() { return mKeyIdentifier; } + + @NotNull + @Override + public String getCipherTransformation() { + return MOCK_CIPHER_TRANSFORMATION; + } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java index 3028a5506a..655946512f 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java @@ -25,6 +25,8 @@ import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; import com.microsoft.identity.common.java.exception.ClientException; +import org.jetbrains.annotations.NotNull; + import javax.crypto.SecretKey; import lombok.NonNull; @@ -33,6 +35,7 @@ public class MockAES256KeyLoaderWithGetKeyError extends AES256KeyLoader { public static String FAIL_TO_LOAD_KEY_ERROR = "FAIL_TO_LOAD_KEY_ERROR"; public static String MOCK_KEY_IDENTIFIER = "MOCK_ERROR_ID"; public static String MOCK_ERROR = "MOCK_ERROR"; + public static String MOCK_CIPHER_TRANSFORMATION = "MOCK_CIPHER_TRANSFORMATION"; @Override public @NonNull String getAlias() { @@ -48,4 +51,9 @@ public class MockAES256KeyLoaderWithGetKeyError extends AES256KeyLoader { public @NonNull String getKeyTypeIdentifier() { return MOCK_KEY_IDENTIFIER; } + + @NotNull + @Override + public String getCipherTransformation() { + return MOCK_CIPHER_TRANSFORMATION; } } From 1637d5c888a9cc977f61cf0cf8a3bd0af6b752ba Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 11 Jun 2025 22:00:11 -0700 Subject: [PATCH 04/51] nits --- .../identity/common/crypto/AndroidWrappedKeyLoader.java | 1 - .../common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java index 47083b6885..50644d6c8f 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java @@ -476,6 +476,5 @@ private File getKeyFile() { @Override public String getCipherTransformation() { return CIPHER_TRANSFORMATION; - } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java index 655946512f..b1a3e33fd4 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java @@ -55,5 +55,6 @@ public class MockAES256KeyLoaderWithGetKeyError extends AES256KeyLoader { @NotNull @Override public String getCipherTransformation() { - return MOCK_CIPHER_TRANSFORMATION; } + return MOCK_CIPHER_TRANSFORMATION; + } } From baf65c7c522e42a7d93753b80be0675687777e89 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 11 Jun 2025 22:06:48 -0700 Subject: [PATCH 05/51] nits --- .../common/java/crypto/key/AES256KeyLoader.java | 14 ++++++++++---- .../common/java/flighting/CommonFlight.java | 4 +++- .../common/java/crypto/MockAES256KeyLoader.java | 1 + 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java index 77c26f6477..4e9dcc51a6 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java @@ -34,17 +34,23 @@ */ public abstract class AES256KeyLoader implements ISecretKeyLoader { + /** + * Shared instance of AES256SecretKeyGenerator. + * Created once and reused across all instances to avoid unnecessary object creation. + */ + private static final AES256SecretKeyGenerator AES_256_KEY_GENERATOR = new AES256SecretKeyGenerator(); + /** * Returns an AES-256 secret key generator. *

- * This implementation creates a new instance of {@link AES256SecretKeyGenerator} - * which can be used to generate new AES-256 keys when needed. + * This implementation returns a shared instance of {@link AES256SecretKeyGenerator} + * to avoid unnecessary object creation, as the generator is stateless. * - * @return A new instance of {@link AES256SecretKeyGenerator} + * @return A shared instance of {@link AES256SecretKeyGenerator} */ @Override @NotNull public AES256SecretKeyGenerator getSecretKeyGenerator() { - return new AES256SecretKeyGenerator(); + return AES_256_KEY_GENERATOR; } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index 4424ecb7e6..eb09b5b599 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -121,7 +121,9 @@ public enum CommonFlight implements IFlightConfig { */ ENABLE_JS_API_FOR_AUTHUX("EnableJsApiForAuthUx", true), - + /** + * Flight to enable the new Android wrapped key loader. + */ ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER("EnableNewAndroidWrappedKeyLoader", true); private String key; diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java index bf6596d306..10f00ec00b 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java @@ -36,6 +36,7 @@ public class MockAES256KeyLoader extends AES256KeyLoader { public static String DEFAULT_MOCK_KEY_IDENTIFIER = "MOCK_ID"; public static String MOCK_ALIAS = "MOCK_ALIAS"; public static String MOCK_CIPHER_TRANSFORMATION = "MOCK_CIPHER_TRANSFORMATION"; + private final SecretKey mKey; private final String mKeyIdentifier; From 2a9c3de7093073101f15fe418cea94b83f4daeba Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 12 Jun 2025 17:13:20 -0700 Subject: [PATCH 06/51] be able to test --- .../crypto/AndroidWrappedKeyLoaderTest.java | 28 +++++++++---------- .../crypto/AndroidWrappedKeyLoader.java | 2 +- .../crypto/CryptoParameterSpecFactory.kt | 4 +-- .../internal/util/AndroidKeyStoreUtil.java | 4 ++- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java index 0aa9b9e2c7..505f7a9c11 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java @@ -126,7 +126,7 @@ private AlgorithmParameterSpec getMockKeyPairGeneratorSpec(final String alias) { @Test public void testGenerateKey() throws ClientException { - final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyLoader.generateRandomKey(); Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); @@ -134,7 +134,7 @@ public void testGenerateKey() throws ClientException { @Test public void testReadKeyDirectly() throws ClientException { - final AndroidWrappedKeyLoader keyLoader = initKeyLoaderWithKeyEntry(); + final NewAndroidWrappedKeyLoader keyLoader = initKeyLoaderWithKeyEntry(); final SecretKey secretKey = keyLoader.getKey(); final SecretKey storedSecretKey = keyLoader.readSecretKeyFromStorage(); @@ -154,9 +154,9 @@ public void testReadKeyDirectly() throws ClientException { public void testLoadKey() throws ClientException { // Nothing exists. This load key function should generate a key if the key hasn't exist. Assert.assertNull(AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); - Assert.assertNull(FileUtil.readFromFile(getKeyFile(), AndroidWrappedKeyLoader.KEY_FILE_SIZE)); + Assert.assertNull(FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyLoader.KEY_FILE_SIZE)); - final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyLoader.getKey(); final SecretKey key = keyLoader.getKeyCache().getData(); @@ -169,10 +169,10 @@ public void testLoadKey() throws ClientException { @Test public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientException { // Create a new Keystore-wrapped key. - final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); keyLoader.generateRandomKey(); - final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), AndroidWrappedKeyLoader.KEY_FILE_SIZE); + final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyLoader.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); // Overwrite the key file with corrupted data. @@ -196,10 +196,10 @@ public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientExce @Test public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException { // Create a new Keystore-wrapped key. - final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); keyLoader.generateRandomKey(); - final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), AndroidWrappedKeyLoader.KEY_FILE_SIZE); + final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyLoader.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); // Overwrite the key file with corrupted data. @@ -224,7 +224,7 @@ public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException @Test @Ignore public void testPerf_WithCachedKey() throws ClientException { - final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); long timeStartLoop = System.nanoTime(); for (int i = 0; i < TEST_LOOP; i++) { @@ -239,7 +239,7 @@ public void testPerf_WithCachedKey() throws ClientException { @Test @Ignore public void testPerf_NoCachedKey() throws ClientException { - final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); long timeStartLoopNotCached = System.nanoTime(); for (int i = 0; i < 100; i++) { @@ -256,7 +256,7 @@ public void testPerf_NoCachedKey() throws ClientException { */ @Test public void testLoadDeletedKeyStoreKey() throws ClientException { - final AndroidWrappedKeyLoader keyLoader = initKeyLoaderWithKeyEntry(); + final NewAndroidWrappedKeyLoader keyLoader = initKeyLoaderWithKeyEntry(); AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); @@ -267,7 +267,7 @@ public void testLoadDeletedKeyStoreKey() throws ClientException { @Test public void testLoadDeletedKeyFile() throws ClientException { - final AndroidWrappedKeyLoader keyLoader = initKeyLoaderWithKeyEntry(); + final NewAndroidWrappedKeyLoader keyLoader = initKeyLoaderWithKeyEntry(); FileUtil.deleteFile(getKeyFile()); @@ -316,8 +316,8 @@ public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProv } - private AndroidWrappedKeyLoader initKeyLoaderWithKeyEntry() throws ClientException { - final AndroidWrappedKeyLoader keyLoader = new AndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + private NewAndroidWrappedKeyLoader initKeyLoaderWithKeyEntry() throws ClientException { + final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey key = keyLoader.getKey(); Assert.assertNotNull(key); Assert.assertNotNull(keyLoader.getKeyCache().getData()); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java index c8446d3831..6c07fa0498 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java @@ -181,7 +181,7 @@ public synchronized SecretKey getKey() throws ClientException { protected SecretKey generateRandomKey() throws ClientException { final String methodTag = TAG + ":generateRandomKey"; - final SecretKey key = super.generateRandomKey(); + final SecretKey key = getSecretKeyGenerator().generateRandomKey(); saveSecretKeyToStorage(key); Logger.info(methodTag, "New key is generated with thumbprint: " + diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index b97354d7d5..6ad593c5d0 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -109,8 +109,8 @@ class CryptoParameterSpecFactory(private val context: Context, private val keyAl getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) private val keySpecWithoutPurposeKey = getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - private val keySpecWithOAEP = - getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) + private val keySpecWithOAEP = true + //getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) init { val methodTag = "$TAG:init" diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index 7fe243b548..4b45a02ac8 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -23,6 +23,7 @@ package com.microsoft.identity.common.internal.util; import android.os.Build; +import android.util.Log; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.logging.Logger; @@ -362,7 +363,7 @@ public static synchronized byte[] wrap(@NonNull final SecretKey key, final Throwable exception; final String errCode; try { - Logger.verbose(methodTag, "Wrap secret key with a KeyPair."); + Log.i(methodTag, "Wrap a key with algorithm: " + wrapAlgorithm); final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); if (algorithmParameterSpec != null) { wrapCipher.init(Cipher.WRAP_MODE, keyToWrap.getPublic(), algorithmParameterSpec); @@ -421,6 +422,7 @@ public static synchronized SecretKey unwrap(@NonNull final byte[] wrappedKeyBlob final Throwable exception; final String errCode; try { + Log.i(methodTag, "unwrap a key with algorithm: " + wrapAlgorithm); final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); if (algorithmParameterSpec != null) { wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate(), algorithmParameterSpec); From e8a1f8fae8fa249a384a0fb896bb719c66654abd Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 12 Jun 2025 18:12:48 -0700 Subject: [PATCH 07/51] fix interface error --- .../AndroidAuthSdkStorageEncryptionManager.java | 11 +++++------ .../common/crypto/AndroidWrappedKeyLoaderFactory.kt | 1 + .../common/java/crypto/key/AES256KeyLoader.java | 4 ++-- .../java/crypto/key/AES256SecretKeyGenerator.kt | 1 - .../common/java/crypto/key/ISecretKeyGenerator.kt | 3 ++- 5 files changed, 10 insertions(+), 10 deletions(-) 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 af88960ef8..836749c1de 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 @@ -26,7 +26,6 @@ import com.microsoft.identity.common.adal.internal.AuthenticationSettings; import com.microsoft.identity.common.java.crypto.StorageEncryptionManager; -import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader; import com.microsoft.identity.common.java.crypto.key.PredefinedKeyLoader; import com.microsoft.identity.common.logging.Logger; @@ -55,7 +54,7 @@ public class AndroidAuthSdkStorageEncryptionManager extends StorageEncryptionMan public static final String WRAPPED_KEY_FILE_NAME = "adalks"; private final PredefinedKeyLoader mPredefinedKeyLoader; - private final AndroidWrappedKeyLoader mKeyStoreKeyLoader; + private final ISecretKeyLoader mKeyStoreKeyLoader; public AndroidAuthSdkStorageEncryptionManager(@NonNull final Context context) { if (AuthenticationSettings.INSTANCE.getSecretKeyData() == null) { @@ -74,7 +73,7 @@ public AndroidAuthSdkStorageEncryptionManager(@NonNull final Context context) { @Override @NonNull - public AES256KeyLoader getKeyLoaderForEncryption() { + public ISecretKeyLoader getKeyLoaderForEncryption() { if (mPredefinedKeyLoader != null) { return mPredefinedKeyLoader; } @@ -90,14 +89,14 @@ public List getKeyLoaderForDecryption(byte[] cipherText) { final String keyIdentifier = getKeyIdentifierFromCipherText(cipherText); if (PredefinedKeyLoader.USER_PROVIDED_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) { if (mPredefinedKeyLoader != null) { - return Collections.singletonList(mPredefinedKeyLoader); + return Collections.singletonList(mPredefinedKeyLoader); } else { throw new IllegalStateException( "Cipher Text is encrypted by USER_PROVIDED_KEY_IDENTIFIER, " + "but mPredefinedKeyLoader is null."); } - } else if (AndroidWrappedKeyLoader.WRAPPED_KEY_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) { - return Collections.singletonList(mKeyStoreKeyLoader); + } else if (AndroidWrappedKeyLoaderFactory.WRAPPED_KEY_KEY_IDENTIFIER.equalsIgnoreCase(keyIdentifier)) { + return Collections.singletonList(mKeyStoreKeyLoader); } Logger.warn(methodTag, diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt index 3c64fc5828..7bb2269a73 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt @@ -35,6 +35,7 @@ import com.microsoft.identity.common.java.flighting.CommonFlightsManager * different implementations without affecting client code. */ object AndroidWrappedKeyLoaderFactory { + const val WRAPPED_KEY_KEY_IDENTIFIER: String = "A001" /** * Creates an appropriate wrapped key loader instance based on current feature flags. * diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java index 4e9dcc51a6..88aa6339e3 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256KeyLoader.java @@ -38,7 +38,7 @@ public abstract class AES256KeyLoader implements ISecretKeyLoader { * Shared instance of AES256SecretKeyGenerator. * Created once and reused across all instances to avoid unnecessary object creation. */ - private static final AES256SecretKeyGenerator AES_256_KEY_GENERATOR = new AES256SecretKeyGenerator(); + private static final ISecretKeyGenerator AES_256_KEY_GENERATOR = new AES256SecretKeyGenerator(); /** * Returns an AES-256 secret key generator. @@ -50,7 +50,7 @@ public abstract class AES256KeyLoader implements ISecretKeyLoader { */ @Override @NotNull - public AES256SecretKeyGenerator getSecretKeyGenerator() { + public ISecretKeyGenerator getSecretKeyGenerator() { return AES_256_KEY_GENERATOR; } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt index e798d4831c..bd1c1ab1c6 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/AES256SecretKeyGenerator.kt @@ -22,7 +22,6 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.crypto.key -import ISecretKeyGenerator import com.microsoft.identity.common.java.exception.ClientException import com.microsoft.identity.common.java.logging.Logger import java.security.NoSuchAlgorithmException diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt index 1b08f41483..77c459a726 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyGenerator.kt @@ -19,7 +19,8 @@ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE.package com.microsoft.identity.common.java.crypto.key +// THE SOFTWARE. +package com.microsoft.identity.common.java.crypto.key import com.microsoft.identity.common.java.exception.ClientException import javax.crypto.SecretKey From f644d7afb7e4a877b5e323c82226c18d75a932c5 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 12 Jun 2025 18:26:11 -0700 Subject: [PATCH 08/51] fix error --- .../identity/common/java/crypto/key/ISecretKeyLoader.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt index ebb3bd9ce6..426aa328d0 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt @@ -22,7 +22,6 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.crypto.key -import ISecretKeyGenerator import com.microsoft.identity.common.java.exception.ClientException import javax.crypto.SecretKey From 3e203d52c5023c760a3fbff52d55d054f3b3d707 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 12 Jun 2025 18:38:27 -0700 Subject: [PATCH 09/51] fix tests --- .../identity/common/java/crypto/MockAES256KeyLoader.java | 3 +-- .../common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java index 10f00ec00b..9694c6fe25 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoader.java @@ -35,7 +35,6 @@ public class MockAES256KeyLoader extends AES256KeyLoader { public static String DEFAULT_MOCK_KEY_IDENTIFIER = "MOCK_ID"; public static String MOCK_ALIAS = "MOCK_ALIAS"; - public static String MOCK_CIPHER_TRANSFORMATION = "MOCK_CIPHER_TRANSFORMATION"; private final SecretKey mKey; private final String mKeyIdentifier; @@ -69,6 +68,6 @@ public MockAES256KeyLoader(@NonNull final byte[] secretKey, @NotNull @Override public String getCipherTransformation() { - return MOCK_CIPHER_TRANSFORMATION; + return "AES/CBC/PKCS5Padding"; } } diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java index b1a3e33fd4..97091e0ab3 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyLoaderWithGetKeyError.java @@ -35,7 +35,6 @@ public class MockAES256KeyLoaderWithGetKeyError extends AES256KeyLoader { public static String FAIL_TO_LOAD_KEY_ERROR = "FAIL_TO_LOAD_KEY_ERROR"; public static String MOCK_KEY_IDENTIFIER = "MOCK_ERROR_ID"; public static String MOCK_ERROR = "MOCK_ERROR"; - public static String MOCK_CIPHER_TRANSFORMATION = "MOCK_CIPHER_TRANSFORMATION"; @Override public @NonNull String getAlias() { @@ -55,6 +54,6 @@ public class MockAES256KeyLoaderWithGetKeyError extends AES256KeyLoader { @NotNull @Override public String getCipherTransformation() { - return MOCK_CIPHER_TRANSFORMATION; + return "AES/CBC/PKCS5Padding"; } } From db8781af8f7746c3df4a51ee78f0fde2d82a661a Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 16 Jun 2025 13:08:25 -0700 Subject: [PATCH 10/51] clean and upadte --- .../crypto/AndroidWrappedKeyLoaderTest.java | 33 +- .../crypto/AndroidKeyStoreRsaKekManager.java | 219 --------- .../crypto/AndroidKeyStoreRsaKekManager.kt | 330 ++++++++++++++ .../crypto/AndroidWrappedKeyLoader.java | 14 +- .../crypto/AndroidWrappedKeyLoaderFactory.kt | 4 +- .../identity/common/crypto/CipherSpec.kt | 8 + .../crypto/CryptoParameterSpecFactory.kt | 272 ++++++------ .../identity/common/crypto/IKekManager.java | 53 --- .../identity/common/crypto/IKekManager.kt | 79 ++++ .../identity/common/crypto/KeyGenSpec.kt | 8 + .../crypto/NewAndroidWrappedKeyLoader.java | 276 ------------ .../crypto/NewAndroidWrappedKeyLoader.kt | 222 ++++++++++ .../CryptoParameterSpecFactoryTest.java | 414 ++++++++++++++++++ .../common/java/flighting/CommonFlight.java | 5 + .../java/opentelemetry/AttributeName.java | 7 +- .../common/java/opentelemetry/SpanName.java | 1 + .../identity/common/java/util/FileUtil.java | 41 +- 17 files changed, 1267 insertions(+), 719 deletions(-) delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.kt create mode 100644 common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java index 47e3799901..d5d23ef87e 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderTest.java @@ -123,14 +123,6 @@ private AlgorithmParameterSpec getMockKeyPairGeneratorSpec(final String alias) { .build(); } - @Test - public void testGenerateKey() throws ClientException { - final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - final SecretKey secretKey = keyLoader.generateRandomKey(); - - Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); - } - @Test public void testReadKeyDirectly() throws ClientException { final NewAndroidWrappedKeyLoader keyLoader = initKeyLoaderWithKeyEntry(); @@ -169,7 +161,7 @@ public void testLoadKey() throws ClientException { public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientException { // Create a new Keystore-wrapped key. final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - keyLoader.generateRandomKey(); + generateRandomKey(keyLoader); final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyLoader.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); @@ -196,7 +188,7 @@ public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientExce public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException { // Create a new Keystore-wrapped key. final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - keyLoader.generateRandomKey(); + generateRandomKey(keyLoader); final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyLoader.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); @@ -322,4 +314,25 @@ private NewAndroidWrappedKeyLoader initKeyLoaderWithKeyEntry() throws ClientExce Assert.assertNotNull(keyLoader.getKeyCache().getData()); return keyLoader; } + + /** + * Helper method to generate a random key using NewAndroidWrappedKeyLoader. + * This method is used to substitute the call to keyLoader.generateRandomKey() in tests. + * + * @param keyLoader The NewAndroidWrappedKeyLoader instance to use + * @return The generated SecretKey + * @throws ClientException if key generation fails + */ + private SecretKey generateRandomKey(NewAndroidWrappedKeyLoader keyLoader) throws ClientException { + // Get the key will generate a new one if it doesn't exist + SecretKey key = keyLoader.getKey(); + + // Clear the cache to ensure it's regenerated next time + keyLoader.getKeyCache().clear(); + + // Generate a new key by getting the key again which will create a new one + key = keyLoader.getKey(); + + return key; + } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java deleted file mode 100644 index 71b2e6cb1d..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.java +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; -import com.microsoft.identity.common.java.controllers.ExceptionAdapter; -import com.microsoft.identity.common.java.exception.ClientException; -import com.microsoft.identity.common.java.opentelemetry.AttributeName; -import com.microsoft.identity.common.java.opentelemetry.OTelUtility; -import com.microsoft.identity.common.java.opentelemetry.SpanExtension; -import com.microsoft.identity.common.java.opentelemetry.SpanName; -import com.microsoft.identity.common.java.util.StringUtil; -import com.microsoft.identity.common.logging.Logger; - -import java.security.KeyPair; -import java.security.spec.AlgorithmParameterSpec; -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; - -import javax.crypto.SecretKey; - -import io.opentelemetry.api.trace.Span; -import io.opentelemetry.api.trace.StatusCode; -import io.opentelemetry.context.Scope; - - -/** - * Class responsible for generating key pairs used for wrapping secret keys. - * Handles different strategies based on API levels and feature flags. - */ -public class AndroidKeyStoreRsaKekManager implements IKekManager { - private static final String TAG = AndroidKeyStoreRsaKekManager.class.getSimpleName(); - - - /** - * Algorithm used to generate wrapping key. - */ - private static final String KEK_ALGORITHM = "RSA"; - - - private final String mKeyAlias; - - private final CryptoParameterSpecFactory mCryptoParameterSpecFactory; - - - /** - * Constructor for AndroidKeyStoreRsaKekManager. - * - * @param keyAlias The alias for the key to be generated - * @param context The context in which the key will be used, typically an Android Context - */ - public AndroidKeyStoreRsaKekManager(@NonNull final String keyAlias, - @NonNull final Context context - ) { - mKeyAlias = keyAlias; - mCryptoParameterSpecFactory = new CryptoParameterSpecFactory(context, mKeyAlias); - } - - @Override - public boolean kekExists() throws ClientException { - return AndroidKeyStoreUtil.readKey(mKeyAlias) != null; - } - - - @Override - public SecretKey unwrapKey(byte[] wrappedSecretKey, final String SecretKeyAlgorithm) throws ClientException { - final String methodTag = TAG + ":unwrapKey"; - final KeyPair keyPair = AndroidKeyStoreUtil.readKey(mKeyAlias); - if (keyPair == null) { - final ClientException clientException = new ClientException( - ClientException.KEY_LOAD_FAILURE, - "No existing keypair found for alias: " + mKeyAlias - ); - Logger.error(methodTag, clientException.getMessage(), clientException); - throw clientException; - } - final List specs = mCryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); - final LinkedList exceptions = new LinkedList<>(); - for (CryptoParameterSpecFactory.CipherSpec spec : specs) { - try { - // Attempt to unwrap the key using the current spec - return AndroidKeyStoreUtil.unwrap( - wrappedSecretKey, - SecretKeyAlgorithm, - keyPair, - spec.getTransformation(), - spec.getAlgorithmParameterSpecs() - ); - } catch (final Throwable throwable) { - Logger.warn(methodTag, "Failed to unwrap key with spec: " + spec.getTransformation()); - // Continue to the next spec if this one fails - exceptions.add(throwable); - } - } - for (final Throwable exception : exceptions) { - Logger.error(methodTag, "Exception encountered during key pair generation: " + exception.getMessage(),exception); - } - - // If we've tried all specs and failed, set span status and throw the last exception - if (exceptions.isEmpty()) { - exceptions.add( - new ClientException( - ClientException.UNKNOWN_CRYPTO_ERROR, - "Failed to unwrap key after trying all available specs.") - ); - } - throw ExceptionAdapter.clientExceptionFromException(exceptions.getLast()); - } - - public byte[] wrapKey(final SecretKey keyToWrap) throws ClientException { - final String methodTag = TAG + ":wrapKey"; - KeyPair keyPair = AndroidKeyStoreUtil.readKey(mKeyAlias); - if (keyPair == null) { - Logger.info(methodTag, "No existing keypair found for alias. Generating a new keypair."); - keyPair = generateKeyPair(); - } - final CryptoParameterSpecFactory.CipherSpec cipherSpecs = mCryptoParameterSpecFactory.getPrimaryCipherParameterSpec(); - return AndroidKeyStoreUtil.wrap( - keyToWrap, - keyPair, - cipherSpecs.getTransformation(), - cipherSpecs.getAlgorithmParameterSpecs() - ); - } - - @NonNull - private KeyPair generateKeyPair() throws ClientException { - final String methodTag = TAG + ":generateKeyPair"; - final Span span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name(), SpanExtension.current().getSpanContext()); - final List specs = mCryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - // Track the last exception encountered to throw if all attempts fail - final LinkedList exceptions = new LinkedList<>(); - - try (final Scope ignored = SpanExtension.makeCurrentSpan(span)) { - // Try each spec in order of priority - for (CryptoParameterSpecFactory.KeyGenSpec spec : specs) { - try { - final KeyPair keyPair = attemptKeyPairGeneration(spec.getKeyGenParameterSpec()); - - // Log the success with a descriptive name for telemetry - span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), spec.getDescription()); - Logger.info(methodTag, "Successfully generated key pair using: " + spec.getDescription()); - - // Return successful key pair - span.setStatus(StatusCode.OK); - return keyPair; - } catch (final Throwable throwable) { - // Log the failure but continue to the next spec - Logger.warn(methodTag, "Failed to generate keypair with spec: " + spec.getDescription()); - if (!StringUtil.isNullOrEmpty(throwable.getMessage())) { - span.setAttribute(AttributeName.keypair_gen_exception.name(), throwable.getMessage()); - } - exceptions.add(throwable); - } - } - for (final Throwable exception : exceptions) { - Logger.error(methodTag, "Exception encountered during key pair generation: " + exception.getMessage(),exception); - } - - // If we've tried all specs and failed, set span status and throw the last exception - if (exceptions.isEmpty()) { - exceptions.add( - new ClientException( - ClientException.UNKNOWN_CRYPTO_ERROR, - "Failed to generate key pair after trying all available specs.") - ); - } - span.setStatus(StatusCode.ERROR); - span.recordException(exceptions.getLast()); - Logger.error(methodTag, "Failed to generate key pair with all available specs", exceptions.getLast()); - throw ExceptionAdapter.clientExceptionFromException(exceptions.getLast()); - } finally { - span.end(); // Span is ended only once, after all attempts - } - } - - private KeyPair attemptKeyPairGeneration(@lombok.NonNull final AlgorithmParameterSpec keyPairGenSpec) throws ClientException { - final long keypairGenStartTime = System.currentTimeMillis(); - final KeyPair keyPair = AndroidKeyStoreUtil.generateKeyPair(KEK_ALGORITHM, keyPairGenSpec); - recordKeyGenerationTime(keypairGenStartTime); - return keyPair; - } - - private void recordKeyGenerationTime(long keypairGenStartTime) { - long elapsedTime = System.currentTimeMillis() - keypairGenStartTime; - SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name(), elapsedTime); - } - - @NonNull - @Override - public String getCipherTransformation() { - return mCryptoParameterSpecFactory.getPrimaryCipherParameterSpec().getTransformation(); - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt new file mode 100644 index 0000000000..0a954268b1 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt @@ -0,0 +1,330 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import android.content.Context +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil +import com.microsoft.identity.common.java.controllers.ExceptionAdapter +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.opentelemetry.AttributeName +import com.microsoft.identity.common.java.opentelemetry.OTelUtility +import com.microsoft.identity.common.java.opentelemetry.SpanExtension +import com.microsoft.identity.common.java.opentelemetry.SpanName +import com.microsoft.identity.common.logging.Logger +import io.opentelemetry.api.trace.StatusCode +import java.security.KeyPair +import java.security.spec.AlgorithmParameterSpec +import javax.crypto.SecretKey + +/** + * Implementation of [IKekManager] that uses Android KeyStore system with RSA key pairs + * for key encryption key (KEK) management. + * + * + * This class is responsible for generating RSA key pairs used for wrapping (encrypting) and + * unwrapping (decrypting) secret keys used in the application. It leverages the Android KeyStore + * system for secure key storage and handling. + * + * + * The implementation provides robustness through a fallback mechanism that tries multiple + * cipher and key generation specifications in order of preference, allowing for compatibility + * across different Android API levels and device capabilities. + */ +class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( + private val keyAlias: String, + context: Context, + private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory( + context, + keyAlias + ) +) : IKekManager { + + companion object { + private val TAG: String = AndroidKeyStoreRsaKekManager::class.java.simpleName + + /** + * Algorithm used to generate the RSA wrapping key. + * RSA is used for asymmetric key wrapping operations where the public key + * encrypts (wraps) the data key and the private key decrypts (unwraps) it. + */ + private const val KEK_ALGORITHM = "RSA" + } + + /** + * Returns the primary cipher transformation to be used for cryptographic operations. + * + * + * The transformation string specifies the algorithm, mode, and padding in the format + * "algorithm/mode/padding" (e.g., "RSA/ECB/PKCS1Padding"). + * + * @return The cipher transformation string for cryptographic operations + */ + override val cipherTransformation: String = getMainCipherSpec().transformation + + /** + * Checks if a key encryption key (KEK) exists in the Android KeyStore for the specified alias. + * + * @return true if the key pair exists and is accessible, false otherwise + * @throws ClientException if there's an error accessing the Android KeyStore + */ + @Throws(ClientException::class) + override fun kekExists(): Boolean { + return AndroidKeyStoreUtil.readKey(keyAlias) != null + } + + /** + * Unwraps (decrypts) a previously wrapped secret key using the RSA private key + * from the Android KeyStore. + * + * + * This method tries multiple cipher specifications in order of preference to provide + * maximum compatibility across different Android API versions and devices. If the primary + * specification fails, it will attempt fallback specifications. + * + * @param wrappedSecretKey The wrapped (encrypted) key as a byte array + * @param secretKeyAlgorithm The algorithm of the secret key (e.g., "AES") + * @return The unwrapped plaintext SecretKey + * @throws ClientException if the key cannot be unwrapped due to missing key pair, + * invalid wrapped key data, or unsupported cipher specifications + */ + @Throws(ClientException::class) + override fun unwrapKey(wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey { + val methodTag = "$TAG:unwrapKey" + + val span = OTelUtility.createSpanFromParent( + SpanName.KeyPairUnWrap.name, + SpanExtension.current().spanContext + ) + // Attempt to read the key pair from the Android KeyStore + val keyPair = AndroidKeyStoreUtil.readKey(keyAlias) ?: run { + // If no key pair exists, we can't proceed with unwrapping + val error = ClientException( + ClientException.KEY_LOAD_FAILURE, + "No existing keypair found for alias: $keyAlias" + ) + Logger.error(methodTag, error.message, error) + throw error + } + // If we have a key pair, proceed to unwrap the key using available cipher specs + val specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() + val failures = mutableListOf() + try { + SpanExtension.makeCurrentSpan(span).use { _ -> + // Try each spec in order of priority + for ((params, transformation) in specs) { + try { + // Attempt to unwrap the key using the current spec + val unwrappedKey = AndroidKeyStoreUtil.unwrap( + wrappedSecretKey, + secretKeyAlgorithm, + keyPair, + transformation, + params + ) + span.setAttribute( + AttributeName.key_pair_unwrap_transformation.name, + transformation + ) + span.setStatus(StatusCode.OK) + return unwrappedKey + } catch (throwable: Throwable) { + // Continue to the next spec if this one fails + Logger.warn(methodTag, "Failed to unwrap key with spec: $transformation") + failures.add(throwable) + } + } + // If we reach here, all attempts to unwrap the key have failed + failures.forEach { exception -> + Logger.error(methodTag, "Unwrap failure with: ${exception.message}", exception) + } + val finalError = failures.lastOrNull() ?: ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "No specs available to attempt key unwrapping." + ) + span.setStatus(StatusCode.ERROR) + span.recordException(finalError) + throw ExceptionAdapter.clientExceptionFromException(finalError) + + } + } finally { + span.end() // Span is ended only once, after all attempts + } + } + + /** + * Wraps (encrypts) a secret key using the RSA public key from the Android KeyStore. + * + * + * If no key pair exists for the specified alias, this method will automatically + * generate a new key pair before performing the wrapping operation. + * + * @param keyToWrap The plaintext secret key that needs to be wrapped + * @return The wrapped (encrypted) key as a byte array + * @throws ClientException if key generation fails or if wrapping operation fails + */ + @Throws(ClientException::class) + override fun wrapKey(keyToWrap: SecretKey): ByteArray { + val methodTag = "$TAG:wrapKey" + var keyPair = AndroidKeyStoreUtil.readKey(keyAlias) + if (keyPair == null) { + Logger.info(methodTag, "No existing keypair found for alias. Generating a new keypair.") + keyPair = generateKeyPair() + } + + return AndroidKeyStoreUtil.wrap( + keyToWrap, + keyPair, + getMainCipherSpec().transformation, + getMainCipherSpec().algorithmParameterSpecs + ) + } + + /** + * Generates a new RSA key pair and stores it in the Android KeyStore. + * + * + * This method attempts to generate a key pair using multiple key generation specifications + * in order of preference. If the primary specification fails, it will attempt fallback + * specifications to ensure compatibility across different Android versions and devices. + * + * + * The key generation process is traced using OpenTelemetry for monitoring and diagnostics. + * + * @return A new RSA KeyPair stored in the Android KeyStore + * @throws ClientException if all key generation attempts fail + */ + @Throws(ClientException::class) + private fun generateKeyPair(): KeyPair { + val methodTag = "$TAG:generateKeyPair" + val span = OTelUtility.createSpanFromParent( + SpanName.KeyPairGeneration.name, + SpanExtension.current().spanContext + ) + + val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() + val failures = mutableListOf() + try { + SpanExtension.makeCurrentSpan(span).use { _ -> + // Try each spec in order of priority + for ((keyGenParameterSpec, description) in specs) { + try { + val keyPair = attemptKeyPairGeneration(keyGenParameterSpec) + span.setAttribute( + AttributeName.key_pair_gen_successful_method.name, + description + ) + Logger.info( + methodTag, + "Successfully generated key pair using: $description" + ) + span.setStatus(StatusCode.OK) + return keyPair + } catch (throwable: Throwable) { + // Log the failure but continue to the next spec + Logger.warn(methodTag, "Failed to generate keypair with spec: $description") + throwable.message?.let { + span.setAttribute(AttributeName.keypair_gen_exception.name, it) + } + failures.add(throwable) + } + } + + // If we reach here, all attempts to unwrap the key have failed + failures.forEach { exception -> + Logger.error( + methodTag, + "KeyPair generation fail with: ${exception.message}", + exception + ) + } + val finalError = failures.lastOrNull() ?: ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "Failed to generate key pair after trying all available specs." + ) + span.setStatus(StatusCode.ERROR) + span.recordException(finalError) + throw ExceptionAdapter.clientExceptionFromException(finalError) + } + } finally { + span.end() // Span is ended only once, after all attempts + } + } + + /** + * Attempts to generate a key pair using the provided algorithm parameter specification. + * + * + * This method records the time taken for key generation for performance monitoring. + * + * @param keyPairGenSpec The algorithm parameter specification for key generation + * @return The generated KeyPair + * @throws ClientException if key generation fails + */ + @Throws(ClientException::class) + private fun attemptKeyPairGeneration(keyPairGenSpec: AlgorithmParameterSpec): KeyPair { + val keypairGenStartTime = System.currentTimeMillis() + val keyPair = AndroidKeyStoreUtil.generateKeyPair(KEK_ALGORITHM, keyPairGenSpec) + recordKeyGenerationTime(keypairGenStartTime) + return keyPair + } + + /** + * Records the elapsed time for key pair generation in the current span + * for performance monitoring and diagnostics. + * + * @param keypairGenStartTime The timestamp when the key generation process started + */ + private fun recordKeyGenerationTime(keypairGenStartTime: Long) { + val elapsedTime = System.currentTimeMillis() - keypairGenStartTime + SpanExtension.current() + .setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) + } + + /** + * Retrieves the main cipher specification to be used for cryptographic operations. + * + * This method returns the highest priority cipher specification from the available + * specifications provided by the crypto parameter spec factory. The main cipher spec + * is used for key wrapping operations. + * + * @return The highest priority CipherSpec to use for operations + * @throws ClientException if no cipher specifications are available + */ + private fun getMainCipherSpec(): CipherSpec { + val methodTag = "$TAG:getMainCipherSpec" + val specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() + if (specs.isEmpty()) { + // This should never happen, there should always be at least one spec + Logger.error( + methodTag, + "No cipher specifications available for key wrapping.", + null + ) + throw ClientException( + ClientException.NO_SUCH_ALGORITHM, + "No cipher specifications available for key wrapping." + ) + } + return specs[0] + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java index 72f8df80bf..66a452fe87 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoader.java @@ -22,6 +22,8 @@ // THE SOFTWARE. package com.microsoft.identity.common.crypto; +import static com.microsoft.identity.common.crypto.AndroidWrappedKeyLoaderFactory.WRAPPED_KEY_KEY_IDENTIFIER; + import android.content.Context; import android.os.Build; import android.security.KeyPairGeneratorSpec; @@ -81,11 +83,6 @@ public class AndroidWrappedKeyLoader extends AES256KeyLoader { private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final String TAG = AndroidWrappedKeyLoader.class.getSimpleName() + "#"; - /** - * Should KeyStore and key file check for validity before every key load be skipped. - */ - @SuppressFBWarnings("MS_SHOULD_BE_FINAL") - public static boolean sSkipKeyInvalidationCheck = false; /** * Algorithm for key wrapping. @@ -97,11 +94,6 @@ public class AndroidWrappedKeyLoader extends AES256KeyLoader { */ private static final String WRAP_KEY_ALGORITHM = "RSA"; - /** - * Indicate that token item is encrypted with the key loaded in this class. - */ - public static final String WRAPPED_KEY_KEY_IDENTIFIER = "A001"; - // Exposed for testing only. /* package */ static final int KEY_FILE_SIZE = 1024; @@ -121,7 +113,7 @@ public class AndroidWrappedKeyLoader extends AES256KeyLoader { private final CachedData mKeyCache = new CachedData() { @Override public SecretKey getData() { - if (!sSkipKeyInvalidationCheck && + if (!AndroidWrappedKeyLoaderFactory.INSTANCE.getSkipKeyInvalidationCheck() && (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !getKeyFile().exists())) { this.clear(); } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt index 7bb2269a73..805aab53bd 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt @@ -36,6 +36,7 @@ import com.microsoft.identity.common.java.flighting.CommonFlightsManager */ object AndroidWrappedKeyLoaderFactory { const val WRAPPED_KEY_KEY_IDENTIFIER: String = "A001" + var skipKeyInvalidationCheck: Boolean = false /** * Creates an appropriate wrapped key loader instance based on current feature flags. * @@ -59,8 +60,7 @@ object AndroidWrappedKeyLoaderFactory { .isFlightEnabled(CommonFlight.ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER) return if (useNewAndroidWrappedKeyLoader) { - // TODO : Replace with the new loader on the next PR - AndroidWrappedKeyLoader( + NewAndroidWrappedKeyLoader( keyIdentifier, fileName, context diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt new file mode 100644 index 0000000000..789a08f130 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt @@ -0,0 +1,8 @@ +package com.microsoft.identity.common.crypto + +import java.security.spec.AlgorithmParameterSpec + +data class CipherSpec( + val algorithmParameterSpecs: AlgorithmParameterSpec?, + val transformation: String +) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index 6ad593c5d0..dfff12aba9 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -30,6 +30,7 @@ import android.security.keystore.KeyProperties import androidx.annotation.RequiresApi import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager.getFlightsProvider +import com.microsoft.identity.common.java.flighting.IFlightsProvider import com.microsoft.identity.common.logging.Logger import java.math.BigInteger import java.security.spec.AlgorithmParameterSpec @@ -40,34 +41,39 @@ import javax.crypto.spec.OAEPParameterSpec import javax.crypto.spec.PSource import javax.security.auth.x500.X500Principal - - /** * Factory class to create various cryptographic parameter specifications * for key generation and cipher operations. * * This class encapsulates the logic to determine which key generation and cipher specs - * to use based on the Android API level and flight flags. + * to use based on the Android API level and feature flags. It implements a fallback mechanism + * to ensure compatibility across different Android versions and device implementations. * + * Key features: + * - Creates appropriate key generation specifications based on Android API level + * - Supports both modern (API 23+) and legacy key specifications + * - Provides options for different padding schemes (OAEP, PKCS1) + * - Configurable through feature flags for testing different implementations * * The Android Keystore (especially on older devices or some hardware-backed implementations) has limited support for MGF1 digests. * * Specifically: * * It supports OAEP with: - * - * Main Digest: SHA-256 ✅ - * - * MGF1 Digest: SHA-1 ✅ + * - Main Digest: SHA-256 ✅ + * - MGF1 Digest: SHA-1 ✅ * * But not: + * - MGF1 Digest: SHA-256 ❌ (on many devices) * - * MGF1 Digest: SHA-256 ❌ (on many devices) - * + * This factory helps navigate these limitations by providing appropriate fallback mechanisms. */ -class CryptoParameterSpecFactory(private val context: Context, private val keyAlias: String) { +class CryptoParameterSpecFactory( + private val context: Context, private val keyAlias: String, + private val flightsProvider: IFlightsProvider = getFlightsProvider() +) { - companion object { + private companion object { private val TAG = CryptoParameterSpecFactory::class.java.simpleName private const val KEY_SIZE: Int = 2048 private const val MODERN_SPEC_WITH_PURPOSE_WRAP_KEY = "modern_spec_with_wrap_key" @@ -75,125 +81,68 @@ class CryptoParameterSpecFactory(private val context: Context, private val keyAl private const val LEGACY_SPEC = "legacy_key_gen_spec" private const val OAEP_TRANSFORMATION = "RSA/NONE/OAEPwithSHA-256andMGF1Padding" private const val PKCS1_TRANSFORMATION = "RSA/ECB/PKCS1Padding" - - - private val oaepSpec = OAEPParameterSpec( + private val OAEP_SPECS = OAEPParameterSpec( "SHA-256", // main digest "MGF1", // mask generation function MGF1ParameterSpec.SHA1, // MGF1 digest PSource.PSpecified.DEFAULT // label (usually default) ) - - private val legacyCipherSpec = CipherSpec( - algorithmParameterSpecs = null, - transformation = PKCS1_TRANSFORMATION - ) - - private val oaepCipherSpec = CipherSpec( - algorithmParameterSpecs = oaepSpec, - transformation = OAEP_TRANSFORMATION - ) } - data class KeyGenSpec( - val keyGenParameterSpec: AlgorithmParameterSpec, - val description: String - ) - - data class CipherSpec( - val algorithmParameterSpecs: AlgorithmParameterSpec?, - val transformation: String - ) - + // Feature flags to control which key generation specs to use private val keySpecWithPurposeKey = - getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) + flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) private val keySpecWithoutPurposeKey = - getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - private val keySpecWithOAEP = true - //getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) - - init { - val methodTag = "$TAG:init" - Logger.info( - methodTag, - "Initialized with keyAlias: $keyAlias, API level: ${Build.VERSION.SDK_INT}, " + - "With flight flags - PurposeWrapKey: $keySpecWithPurposeKey, " + - "WithoutPurposeKey: $keySpecWithoutPurposeKey, " + - "WithOAEP: $keySpecWithOAEP" - ) - } - - fun getPrimaryCipherParameterSpec(): CipherSpec { - val methodTag = "$TAG:getCipherTransformation" - val spec = if (keySpecWithOAEP) { - oaepCipherSpec - } else { - legacyCipherSpec - } - Logger.info(methodTag, "Using cipher transformation: ${spec.transformation}") - return spec - } + flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) + private val keySpecWithOAEP = + flightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) - fun getPrioritizedCipherParameterSpecs(): List { - val specs = mutableListOf() - if (keySpecWithOAEP) { - specs.add(oaepCipherSpec) - } - specs.add(legacyCipherSpec) - return specs - } - - /** - * Returns a prioritized list of AlgorithmParameterSpec objects to try in sequence. - * This helps handle fallback scenarios where the preferred spec might not work - * on all devices or with all existing keys. - * - * @return List of AlgorithmParameterSpec objects ordered by priority (highest first) - */ - fun getPrioritizedKeyGenParameterSpecs(): List { - val methodTag = "$TAG:getPrioritizedKeyGenParameterSpecs" + // Cipher parameter specifications + private val legacyCipherSpec = CipherSpec( + algorithmParameterSpecs = null, + transformation = PKCS1_TRANSFORMATION + ) - val specs = mutableListOf() - // Add specs in order of preference - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && keySpecWithPurposeKey) { - // First priority: API 28+ with PURPOSE_WRAP_KEY if enabled - specs.add( - KeyGenSpec( - getKeyGenParamSpecWithPurposeWrapKey(), - MODERN_SPEC_WITH_PURPOSE_WRAP_KEY - ) - ) - } + private val oaepCipherSpec = CipherSpec( + algorithmParameterSpecs = OAEP_SPECS, + transformation = OAEP_TRANSFORMATION + ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && keySpecWithoutPurposeKey) { - // Second priority: API 23+ without PURPOSE_WRAP_KEY - specs.add( - KeyGenSpec( - getKeyGenParamSpecWithoutPurposeWrapKey(), - MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY - ) - ) - } + // Key generation parameter specifications + @RequiresApi(Build.VERSION_CODES.P) + private val keyGenParamSpecWithPurposeWrapKey = KeyGenSpec( + keyGenParameterSpec = getAlgorithmParameterSpec( + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT or + KeyProperties.PURPOSE_WRAP_KEY + ), + description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY + ) - // Always include legacy spec as last resort fallback - specs.add(KeyGenSpec(getLegacyKeyGenParamSpec(), LEGACY_SPEC)) + @RequiresApi(Build.VERSION_CODES.M) + private val keyGenParamSpecWithoutPurposeWrapKey = KeyGenSpec( + keyGenParameterSpec = getAlgorithmParameterSpec( + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT + ), + description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY + ) - Logger.info( - methodTag, - "Created prioritized specs list with ${specs.size} options: ${specs.joinToString { it.description }}" - ) - return specs - } + private val keyGenParamSpecLegacy = KeyGenSpec( + keyGenParameterSpec = getLegacyKeyGenParamSpec(), + description = LEGACY_SPEC + ) /** - * Generate a self-signed cert and derive an AlgorithmParameterSpec from that. - * This is for the key to be generated in {@link KeyStore} via {@link KeyPairGenerator} - * Note : This is now only for API level < 23 or as fallback. + * Generates a legacy algorithm parameter specification using KeyPairGeneratorSpec. * - * @return a {@link AlgorithmParameterSpec} for the keystore key (that we'll use to wrap the secret key). + * This approach is used for API levels below 23 (Android M) or as a fallback + * when more modern specifications fail. It creates a self-signed certificate + * with a 100-year validity period. + * + * @return A [KeyPairGeneratorSpec] configured for the key alias and application context */ private fun getLegacyKeyGenParamSpec(): AlgorithmParameterSpec { - // Generate a self-signed cert. val certInfo = String.format( Locale.ROOT, "CN=%s, OU=%s", @@ -214,24 +163,16 @@ class CryptoParameterSpecFactory(private val context: Context, private val keyAl .build() } - - @RequiresApi(Build.VERSION_CODES.P) - private fun getKeyGenParamSpecWithPurposeWrapKey(): AlgorithmParameterSpec { - return getAlgorithmParameterSpec( - KeyProperties.PURPOSE_ENCRYPT or - KeyProperties.PURPOSE_DECRYPT or - KeyProperties.PURPOSE_WRAP_KEY - ) - } - - @RequiresApi(Build.VERSION_CODES.M) - private fun getKeyGenParamSpecWithoutPurposeWrapKey(): AlgorithmParameterSpec { - return getAlgorithmParameterSpec( - KeyProperties.PURPOSE_ENCRYPT or - KeyProperties.PURPOSE_DECRYPT - ) - } - + /** + * Helper method to create an appropriate key generation parameter specification. + * + * This method configures the specification with the appropriate padding and digest + * algorithms based on feature flags. It supports both OAEP (stronger) and PKCS1 + * (more compatible) padding schemes. + * + * @param purposes The key usage purposes (combinations of KeyProperties.PURPOSE_* constants) + * @return A [KeyGenParameterSpec] configured according to current settings + */ @RequiresApi(api = Build.VERSION_CODES.M) private fun getAlgorithmParameterSpec(purposes: Int): AlgorithmParameterSpec { val methodTag = "$TAG:getSpecForWrappingKey" @@ -252,4 +193,77 @@ class CryptoParameterSpecFactory(private val context: Context, private val keyAl .build() } } + + init { + val methodTag = "$TAG:init" + Logger.info( + methodTag, + "Initialized with keyAlias: $keyAlias, API level: ${Build.VERSION.SDK_INT}, " + + "With flight flags - PurposeWrapKey: $keySpecWithPurposeKey, " + + "WithoutPurposeKey: $keySpecWithoutPurposeKey, " + + "WithOAEP: $keySpecWithOAEP" + ) + } + + /** + * Returns a prioritized list of cipher parameter specifications to try in sequence. + * + * This allows the calling code to attempt operations with the most secure/preferred + * specification first, then fall back to more compatible options if needed. + * + * The list is ordered with the most preferred specification first (OAEP if enabled, then PKCS1). + * + * @return List of [CipherSpec] objects ordered by preference (highest priority first) + */ + fun getPrioritizedCipherParameterSpecs(): List { + val methodTag = "$TAG:getPrioritizedCipherParameterSpecs" + val specs = mutableListOf() + // Add OAEP padding spec first (if enabled) as it provides stronger security + if (keySpecWithOAEP) { + specs.add(oaepCipherSpec) + } + // Always include legacy PKCS1 padding as a fallback for compatibility + specs.add(legacyCipherSpec) + Logger.info(methodTag, "Options: ${specs.joinToString { it.transformation }}") + return specs + } + + /** + * Returns a prioritized list of key generation parameter specifications to try in sequence. + * + * This helps handle fallback scenarios where the preferred spec might not work + * on all devices or with all existing keys. Each specification has a descriptive + * identifier for logging and debugging purposes. + * + * The method considers: + * 1. Android API level (supporting modern APIs from Android M/23 and P/28) + * 2. Feature flags that enable/disable specific key generation approaches + * 3. Backward compatibility with existing keys + * + * The list always includes a legacy specification as a last resort fallback option. + * + * @return List of [KeyGenSpec] objects ordered by priority (highest first) + */ + fun getPrioritizedKeyGenParameterSpecs(): List { + val methodTag = "$TAG:getPrioritizedKeyGenParameterSpecs" + + val specs = mutableListOf() + + // Add specs in order of preference + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && keySpecWithPurposeKey) { + // First priority: API 28+ with PURPOSE_WRAP_KEY if enabled + specs.add(keyGenParamSpecWithPurposeWrapKey) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && keySpecWithoutPurposeKey) { + // Second priority: API 23+ without PURPOSE_WRAP_KEY + specs.add(keyGenParamSpecWithoutPurposeWrapKey) + } + + // Always include legacy spec as last resort fallback + specs.add(keyGenParamSpecLegacy) + + Logger.info(methodTag, "Options: ${specs.joinToString { it.description }}") + return specs + } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java deleted file mode 100644 index bb5bcb5139..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.java +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto; - -import androidx.annotation.NonNull; - -import com.microsoft.identity.common.java.exception.ClientException; - -import javax.crypto.SecretKey; - -/** - * Interface for key encryption key (KEK) generation. - * Defines the contract for generating key pairs used to wrap/unwrap secret keys. - * Implementations can handle different algorithms and API versions. - */ -public interface IKekManager { - - /** - * Gets the appropriate cipher transformation to use with the generated keys. - * The transformation string specifies the algorithm, mode, and padding to be - * used for encryption/decryption operations. - * - * @return The cipher transformation string in the format "algorithm/mode/padding" - */ - @NonNull - String getCipherTransformation(); - - byte [] wrapKey(final SecretKey keyToWrap) throws ClientException; - - SecretKey unwrapKey(final byte [] wrappedSecretKey, final String SecretKeyAlgorithm) throws ClientException; - - boolean kekExists() throws ClientException; -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt new file mode 100644 index 0000000000..a149e7b1f9 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import com.microsoft.identity.common.java.exception.ClientException +import javax.crypto.SecretKey + +/** + * Interface for key encryption key (KEK) generation and management. + * Defines the contract for generating and managing key pairs used to wrap/unwrap secret keys. + * Implementations can handle different algorithms and API versions. + */ +interface IKekManager { + /** + * Gets the appropriate cipher transformation to use with the generated keys. + * The transformation string specifies the algorithm, mode, and padding to be + * used for encryption/decryption operations. + * + * @return The cipher transformation string in the format "algorithm/mode/padding" + */ + val cipherTransformation: String + + /** + * Wraps (encrypts) a secret key using the Key Encryption Key managed by this interface. + * The wrapped key can be safely stored and later unwrapped using the [.unwrapKey] method. + * + * @param keyToWrap The plaintext secret key that needs to be wrapped + * @return The wrapped (encrypted) key as a byte array + * @throws ClientException If wrapping fails due to cryptographic errors, key unavailability, + * or insufficient permissions + */ + @Throws(ClientException::class) + fun wrapKey(keyToWrap: SecretKey): ByteArray + + /** + * Unwraps (decrypts) a previously wrapped secret key using the Key Encryption Key. + * + * @param wrappedSecretKey The wrapped (encrypted) key as a byte array + * @param secretKeyAlgorithm The algorithm name of the wrapped secret key (e.g., "AES") + * needed to properly reconstruct the key after unwrapping + * @return The unwrapped plaintext secret key + * @throws ClientException If unwrapping fails due to cryptographic errors, + * key unavailability, tampered wrapped key, + * or insufficient permissions + */ + @Throws(ClientException::class) + fun unwrapKey(wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey + + /** + * Checks if the Key Encryption Key exists and is accessible. + * This can be used to verify if a KEK is available before attempting operations. + * + * @return true if the KEK exists and is accessible, false otherwise + * @throws ClientException If checking for key existence fails due to + * security framework errors or insufficient permissions + */ + @Throws(ClientException::class) + fun kekExists(): Boolean +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt new file mode 100644 index 0000000000..91aa733110 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt @@ -0,0 +1,8 @@ +package com.microsoft.identity.common.crypto + +import java.security.spec.AlgorithmParameterSpec + +data class KeyGenSpec( + val keyGenParameterSpec: AlgorithmParameterSpec, + val description: String +) \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java deleted file mode 100644 index a92ec4af75..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.java +++ /dev/null @@ -1,276 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto; - -import android.content.Context; - -import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; -import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader; -import com.microsoft.identity.common.java.crypto.key.KeyUtil; -import com.microsoft.identity.common.java.exception.ClientException; -import com.microsoft.identity.common.java.util.CachedData; -import com.microsoft.identity.common.java.util.FileUtil; -import com.microsoft.identity.common.logging.Logger; - -import org.jetbrains.annotations.NotNull; - -import java.io.File; - -import javax.crypto.SecretKey; - -import edu.umd.cs.findbugs.annotations.Nullable; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import lombok.NonNull; - -/** - * This class doesn't really use the KeyStore-generated key directly. - *

- * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key - * before it get saved to the file. - */ -public class NewAndroidWrappedKeyLoader extends AES256KeyLoader { - private static final String TAG = NewAndroidWrappedKeyLoader.class.getSimpleName() + "#"; - - /** - * Should KeyStore and key file check for validity before every key load be skipped. - */ - @SuppressFBWarnings("MS_SHOULD_BE_FINAL") - public static boolean sSkipKeyInvalidationCheck = false; - - /** - * Indicate that token item is encrypted with the key loaded in this class. - */ - public static final String WRAPPED_KEY_KEY_IDENTIFIER = "A001"; - - // Exposed for testing only. - public static final int KEY_FILE_SIZE = 1024; - - // Exposed for testing only. - public static final String SECRET_KEY_ALGORITHM_FILE = "key_algorithm_file"; - - private final Context mContext; - - /** - * Name of the key itself. Must be unique. - */ - private final String mAlias; - - private final IKekManager mKekManager; - - /** - * Name of the file contains the wrapped symmetric key used for encryption/decryption. - * Must be unique. - */ - private final String mFilePath; - - private final CachedData mKeyCache = new CachedData() { - @Override - public SecretKey getData() { - if (!sSkipKeyInvalidationCheck && - (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !getKeyFile().exists())) { - this.clear(); - } - return super.getData(); - } - }; - - // Exposed for testing only. - @NonNull - /* package */ CachedData getKeyCache() { - return mKeyCache; - } - - /** - * Default constructor - * - * @param alias Alias(name) of the wrapping key. - * @param filePath Path to the file for storing the wrapped key. - * @param context Android's {@link Context} - */ - public NewAndroidWrappedKeyLoader(@NonNull final String alias, - @NonNull final String filePath, - @NonNull final Context context) { - mAlias = alias; - mFilePath = filePath; - mContext = context; - mKekManager = new AndroidKeyStoreRsaKekManager(mAlias, mContext); - } - - @Override - @NonNull - public String getAlias() { - return mAlias; - } - - @Override - @NonNull - public String getKeyTypeIdentifier() { - return WRAPPED_KEY_KEY_IDENTIFIER; - } - - /** - * If key is already generated, that one will be returned. - * Otherwise, generate a new one and return. - */ - @Override - @NonNull - public synchronized SecretKey getKey() throws ClientException { - SecretKey key = mKeyCache.getData(); - - if (key == null) { - key = readSecretKeyFromStorage(); - } - - // If key doesn't exist, generate a new one. - if (key == null) { - key = generateRandomKey(); - } - - mKeyCache.setData(key); - return key; - } - - @NonNull - protected SecretKey generateRandomKey() throws ClientException { - final String methodTag = TAG + ":generateRandomKey"; - - final SecretKey key = getSecretKeyGenerator().generateRandomKey(); - saveSecretKeyToStorage(key, key.getAlgorithm()); - - Logger.info(methodTag, "New key is generated with thumbprint: " + - KeyUtil.getKeyThumbPrint(key)); - - return key; - } - - /** - * Load the saved keystore-encrypted key. Will only do read operation. - * - * @return SecretKey. Null if there isn't any. - */ - @Nullable - /* package */ synchronized SecretKey readSecretKeyFromStorage() throws ClientException { - final String methodTag = TAG + ":readSecretKeyFromStorage"; - try { - - if (!mKekManager.kekExists()) { - Logger.info(methodTag, "key does not exist in keystore"); - deleteSecretKeyFromStorage(); - return null; - } - - final byte[] wrappedSecretKey = FileUtil.readFromFile(getKeyFile(), KEY_FILE_SIZE); - if (wrappedSecretKey == null) { - Logger.warn(methodTag, "Key file is empty"); - // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache - // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. - FileUtil.deleteFile(getKeyFile()); - FileUtil.deleteFile(getKeyAlgorithmFile()); - mKeyCache.clear(); - return null; - } - - String keyAlgorithm = FileUtil.readStringFromFile(getKeyAlgorithmFile()); - if (keyAlgorithm == null || keyAlgorithm.isEmpty()) { - keyAlgorithm = getSecretKeyGenerator().getKeyAlgorithm(); - } - - - final SecretKey key = mKekManager.unwrapKey(wrappedSecretKey, keyAlgorithm); - - - Logger.info(methodTag, "Key is loaded with thumbprint: " + - KeyUtil.getKeyThumbPrint(key)); - - return key; - } catch (final ClientException e) { - // Reset KeyPair info so that new request will generate correct KeyPairs. - // All tokens with previous SecretKey are not possible to decrypt. - Logger.warn(methodTag, "Error when loading key from Storage, " + - "wipe all existing key data "); - deleteSecretKeyFromStorage(); - throw e; - } - } - - /** - * Encrypt the given unencrypted symmetric key with Keystore key and save to storage. - */ - private void saveSecretKeyToStorage(@NonNull final SecretKey unencryptedKey, - @NonNull final String keyAlgorithm) throws ClientException { - /* - * !!WARNING!! - * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring - * the sharedUserId attribute in their Android Manifest file. If multiple apps reference - * the same value for sharedUserId and are signed with the same keys, they will use - * the same AndroidKeyStore and may obtain access to the files and shared preferences - * of other applications by invoking createPackageContext. - * - * Support for sharedUserId is deprecated, however some applications still use this Android capability. - * See: https://developer.android.com/guide/topics/manifest/manifest-element - * - * To address apps in this scenario we will attempt to load an existing KeyPair - * instead of immediately generating a new key pair. This will use the same keypair - * to encrypt the symmetric key generated separately for each - * application using a shared linux user id... and avoid these applications from - * stomping/overwriting one another's keypair. - */ - final byte[] keyWrapped = mKekManager.wrapKey(unencryptedKey); - FileUtil.writeDataToFile(keyWrapped, getKeyFile()); - FileUtil.writeStringToFile(keyAlgorithm, getKeyAlgorithmFile()); - } - /** - * Wipe all the data associated from this key. - */ - // VisibleForTesting - public void deleteSecretKeyFromStorage() throws ClientException { - AndroidKeyStoreUtil.deleteKey(mAlias); - FileUtil.deleteFile(getKeyFile()); - FileUtil.deleteFile(getKeyAlgorithmFile()); - mKeyCache.clear(); - } - - - /** - * Get the file that stores the wrapped key. - */ - private File getKeyFile() { - return new File( - mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), - mFilePath); - } - - /** - * Get the file that stores the wrapped key. - */ - private File getKeyAlgorithmFile() { - return new File( - mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), - SECRET_KEY_ALGORITHM_FILE); - } - - @Override - public @NotNull String getCipherTransformation() { - return mKekManager.getCipherTransformation(); - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.kt b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.kt new file mode 100644 index 0000000000..bd0c6c3e94 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.kt @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import android.content.Context +import com.microsoft.identity.common.crypto.AndroidWrappedKeyLoaderFactory.WRAPPED_KEY_KEY_IDENTIFIER +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil +import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader +import com.microsoft.identity.common.java.crypto.key.KeyUtil +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.util.CachedData +import com.microsoft.identity.common.java.util.FileUtil +import com.microsoft.identity.common.logging.Logger +import java.io.File +import javax.crypto.SecretKey + +/** + * This class doesn't really use the KeyStore-generated key directly. + * + * + * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key + * before it get saved to the file. + */ +class NewAndroidWrappedKeyLoader @JvmOverloads constructor( + override val alias: String, + private val mFilePath: String, + private val mContext: Context, + private val mKekManager: IKekManager = AndroidKeyStoreRsaKekManager(alias, mContext) +) : AES256KeyLoader() { + + + // Exposed for testing only. + val keyCache: CachedData = object : CachedData() { + override fun getData(): SecretKey? { + if (AndroidWrappedKeyLoaderFactory.skipKeyInvalidationCheck) { + return super.getData() + } + if ((!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists())) { + this.clear() + } + return super.getData() + } + } + + + + /** + * If key is already generated, that one will be returned. + * Otherwise, generate a new one and return. + */ + @get:Throws(ClientException::class) + @get:Synchronized + override val key: SecretKey + get() { + val methodTag = "$TAG:key" + keyCache.data?.let { keyOnCache -> + Logger.info( + methodTag, "Key is loaded from cache with thumbprint: " + + KeyUtil.getKeyThumbPrint(keyOnCache) + ) + return keyOnCache + } + readSecretKeyFromStorage()?.let { keyFromStorage -> + Logger.info( + methodTag, "Key is loaded from storage with thumbprint: " + + KeyUtil.getKeyThumbPrint(keyFromStorage) + ) + keyCache.data = keyFromStorage + return keyFromStorage + } + secretKeyGenerator.generateRandomKey().let { newKey -> + Logger.info( + methodTag, "New key is generated with thumbprint: " + + KeyUtil.getKeyThumbPrint(newKey) + ) + saveSecretKeyToStorage(newKey, newKey.algorithm) + keyCache.data = newKey + return newKey + } + } + + + /** + * Load the saved keystore-encrypted key. Will only do read operation. + * + * @return SecretKey. Null if there isn't any. + */ + /* package */@Synchronized + @Throws(ClientException::class) + fun readSecretKeyFromStorage(): SecretKey? { + val methodTag = "$TAG:readSecretKeyFromStorage" + try { + if (!mKekManager.kekExists()) { + Logger.info(methodTag, "key does not exist in keystore") + deleteSecretKeyFromStorage() + return null + } + val wrappedSecretKey = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) ?: run { + Logger.warn(methodTag, "Key file is empty") + // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache + // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. + FileUtil.deleteFile(keyFile) + FileUtil.deleteFile(keyAlgorithmFile) + keyCache.clear() + return null + } + val keyAlgorithm = FileUtil.readStringFromFile(keyAlgorithmFile) ?: run { + Logger.warn( + methodTag, "Key algorithm file is empty, " + + "using SecretKeyGenerator to get the key algorithm" + ) + secretKeyGenerator.keyAlgorithm + } + val key = mKekManager.unwrapKey(wrappedSecretKey, keyAlgorithm) + Logger.info( + methodTag, + "Key is loaded with thumbprint: " + KeyUtil.getKeyThumbPrint(key) + ) + return key + } catch (e: ClientException) { + // Reset KeyPair info so that new request will generate correct KeyPairs. + // All tokens with previous SecretKey are not possible to decrypt. + Logger.warn( + methodTag, "Error when loading key from Storage, " + + "wipe all existing key data " + ) + deleteSecretKeyFromStorage() + throw e + } + } + + /** + * Encrypt the given unencrypted symmetric key with Keystore key and save to storage. + */ + @Throws(ClientException::class) + private fun saveSecretKeyToStorage( + unencryptedKey: SecretKey, + keyAlgorithm: String + ) { + /* + * !!WARNING!! + * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring + * the sharedUserId attribute in their Android Manifest file. If multiple apps reference + * the same value for sharedUserId and are signed with the same keys, they will use + * the same AndroidKeyStore and may obtain access to the files and shared preferences + * of other applications by invoking createPackageContext. + * + * Support for sharedUserId is deprecated, however some applications still use this Android capability. + * See: https://developer.android.com/guide/topics/manifest/manifest-element + * + * To address apps in this scenario we will attempt to load an existing KeyPair + * instead of immediately generating a new key pair. This will use the same keypair + * to encrypt the symmetric key generated separately for each + * application using a shared linux user id... and avoid these applications from + * stomping/overwriting one another's keypair. + */ + val keyWrapped = mKekManager.wrapKey(unencryptedKey) + FileUtil.writeDataToFile(keyWrapped, keyFile) + FileUtil.writeStringToFile(keyAlgorithm, keyAlgorithmFile) + } + + /** + * Wipe all the data associated from this key. + */ + // VisibleForTesting + @Throws(ClientException::class) + fun deleteSecretKeyFromStorage() { + AndroidKeyStoreUtil.deleteKey(alias) + FileUtil.deleteFile(keyFile) + FileUtil.deleteFile(keyAlgorithmFile) + keyCache.clear() + } + + private val keyFile: File + get() = File( + mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), + mFilePath + ) + + private val keyAlgorithmFile: File + get() = File( + mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), + SECRET_KEY_ALGORITHM_FILE + ) + + override val cipherTransformation: String + get() = mKekManager.cipherTransformation + + override val keyTypeIdentifier: String + get() = WRAPPED_KEY_KEY_IDENTIFIER + + companion object { + private val TAG = NewAndroidWrappedKeyLoader::class.java.simpleName + + // Exposed for testing only. + const val KEY_FILE_SIZE: Int = 1024 + + // Exposed for testing only. + const val SECRET_KEY_ALGORITHM_FILE: String = "key_algorithm_file" + } +} + diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java new file mode 100644 index 0000000000..ec55dd95ce --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java @@ -0,0 +1,414 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import static org.junit.Assert.*; + +import android.content.Context; +import android.os.Build; + +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.IFlightConfig; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; + +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.security.spec.MGF1ParameterSpec; +import java.util.List; + +import javax.crypto.spec.OAEPParameterSpec; + +import lombok.NonNull; + +/** + * Unit tests for {@link CryptoParameterSpecFactory} + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {Build.VERSION_CODES.P}) // Targeting Android 9.0 (API 28) for the tests +public class CryptoParameterSpecFactoryTest { + + private static final String TEST_KEY_ALIAS = "test_key_alias"; + + @Mock + private Context mockContext; + + private IFlightsProvider mockFlightsProvider; + + private CryptoParameterSpecFactory cryptoParameterSpecFactory; + + private Boolean oeapEnabled = true; // Simulate OAEP enabled by default + private Boolean newKeyGenSpecWithWrapKeyEnabled = true; // Simulate new key gen spec with wrap key enabled + private Boolean newKeyGenSpecWithoutWrapKeyEnabled = true; // Simulate new key gen spec without wrap key enabled + + @Before + public void setUp() { + + // Setup mock flights provider + mockFlightsProvider = new IFlightsProvider() { + + @Override + public boolean isFlightEnabled(@NonNull IFlightConfig flightConfig) { + if (flightConfig.getKey().equals(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING.getKey())) { + // Simulate OAEP enabled for testing + return oeapEnabled; // Change to false to test PKCS1 path + } else if (flightConfig.getKey().equals(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY.getKey())) { + return newKeyGenSpecWithWrapKeyEnabled; // Simulate new key gen spec enabled + } else if (flightConfig.getKey().equals(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY.getKey())) { + return newKeyGenSpecWithoutWrapKeyEnabled; // Simulate modern spec without wrap key enabled + } + return false; + } + + @Override + public boolean getBooleanValue(@NonNull IFlightConfig flightConfig) { + return false; + } + + @Override + public int getIntValue(@NonNull IFlightConfig flightConfig) { + return 0; + } + + @Override + public double getDoubleValue(@NonNull IFlightConfig flightConfig) { + return 0; + } + + @Override + public String getStringValue(@NonNull IFlightConfig flightConfig) { + return ""; + } + + @Override + public JSONObject getJsonValue(@NonNull IFlightConfig flightConfig) { + return null; + } + }; + + + mockContext = org.mockito.Mockito.mock(Context.class); + // Create the instance to test + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + } + + + + @Test + public void testGetPrioritizedCipherParameterSpec_WithOAEPEnabled() { + // When OAEP is enabled + //mockFlightsProvider.addFlight(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING.getKey(), "true"); + + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS,mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); + + // Verify we have 2 specs (OAEP and PKCS1) in that order + assertEquals(2, specs.size()); + assertEquals("RSA/NONE/OAEPwithSHA-256andMGF1Padding", specs.get(0).getTransformation()); + assertEquals("RSA/ECB/PKCS1Padding", specs.get(1).getTransformation()); + } + + @Test + public void testGetPrioritizedCipherParameterSpec_WithOAEPDisabled() { + // When OAEP is disabled + oeapEnabled = false; + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); + + // Verify we have only 1 spec (PKCS1) + assertEquals(1, specs.size()); + assertEquals("RSA/ECB/PKCS1Padding", specs.get(0).getTransformation()); + } + + @Test + public void testGetPrioritizedKeyGenParameterSpecs_AllFlagsEnabled() { + // When all flags are enabled + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + + // Verify we have 3 specs in the right order + assertEquals(3, specs.size()); + assertEquals("modern_spec_with_wrap_key", specs.get(0).getDescription()); + assertEquals("modern_spec_without_wrap_key", specs.get(1).getDescription()); + assertEquals("legacy_key_gen_spec", specs.get(2).getDescription()); + } + + @Test + public void testGetPrioritizedKeyGenParameterSpecs_WithoutPurposeWrapKeyOnly() { + // When only WITHOUT_PURPOSE_WRAP_KEY flag is enabled + newKeyGenSpecWithWrapKeyEnabled = false; // Simulate wrap key disabled + newKeyGenSpecWithoutWrapKeyEnabled = true; // Simulate modern spec without wrap key enabled + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + + // Verify we have 2 specs in the right order + assertEquals(2, specs.size()); + assertEquals("modern_spec_without_wrap_key", specs.get(0).getDescription()); + assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); + } + + @Test + public void testGetPrioritizedKeyGenParameterSpecs_NoFlagsEnabled() { + // When no flags are enabled + newKeyGenSpecWithWrapKeyEnabled = false; // Simulate wrap key disabled + newKeyGenSpecWithoutWrapKeyEnabled = false; // Simulate modern spec without wrap key disabled + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + + // Verify we have only the legacy spec + assertEquals(1, specs.size()); + assertEquals("legacy_key_gen_spec", specs.get(0).getDescription()); + } + + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetPrioritizedKeyGenParameterSpecs_API23_WithFlags() { + // Test on API 23 (M) with flags enabled + // Should include modern spec without wrap key but not the one with wrap key (requires API 28) + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + + // Verify we have 2 specs in the right order (no PURPOSE_WRAP_KEY since it needs API 28) + assertEquals(2, specs.size()); + assertEquals("modern_spec_without_wrap_key", specs.get(0).getDescription()); + assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); + } + + @Test + @Config(sdk = Build.VERSION_CODES.LOLLIPOP) // API 21, before M + public void testGetPrioritizedKeyGenParameterSpecs_LegacyAPI() { + // Test on pre-M API where only legacy spec should be available + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + + // Verify we have only the legacy spec regardless of flags + assertEquals(1, specs.size()); + assertEquals("legacy_key_gen_spec", specs.get(0).getDescription()); + } + + @Test + public void testGetAlgorithmParameterSpec_WithOAEPEnabled() { + // When OAEP is enabled + oeapEnabled = true; + + // Re-create the factory + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Call the method via reflection since it's private + try { + java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( + "getAlgorithmParameterSpec", int.class); + method.setAccessible(true); + + Object spec = method.invoke(cryptoParameterSpecFactory, android.security.keystore.KeyProperties.PURPOSE_ENCRYPT); + + assertNotNull("Algorithm parameter spec should not be null", spec); + assertTrue("Should return a KeyGenParameterSpec", + spec instanceof android.security.keystore.KeyGenParameterSpec); + + // For OAEP, it should include DIGEST_SHA256 and DIGEST_SHA1 + android.security.keystore.KeyGenParameterSpec keySpec = + (android.security.keystore.KeyGenParameterSpec) spec; + + // Unfortunately we can't directly check the digests, but we can verify + // the padding is set correctly if OAEP is enabled + java.lang.reflect.Field builderField = keySpec.getClass().getDeclaredField("mEncryptionPaddings"); + builderField.setAccessible(true); + String[] paddings = (String[]) builderField.get(keySpec); + + assertEquals(1, paddings.length); + assertEquals(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, paddings[0]); + + } catch (Exception e) { + fail("Failed to test getAlgorithmParameterSpec: " + e.getMessage()); + } + } + + @Test + public void testGetAlgorithmParameterSpec_WithOAEPDisabled() { + // When OAEP is disabled + oeapEnabled = false; + + // Re-create the factory + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Call the method via reflection since it's private + try { + java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( + "getAlgorithmParameterSpec", int.class); + method.setAccessible(true); + + Object spec = method.invoke(cryptoParameterSpecFactory, android.security.keystore.KeyProperties.PURPOSE_ENCRYPT); + + assertNotNull("Algorithm parameter spec should not be null", spec); + assertTrue("Should return a KeyGenParameterSpec", + spec instanceof android.security.keystore.KeyGenParameterSpec); + + // For PKCS1, it should include DIGEST_SHA256 and DIGEST_SHA512 + android.security.keystore.KeyGenParameterSpec keySpec = + (android.security.keystore.KeyGenParameterSpec) spec; + + // Unfortunately we can't directly check the digests, but we can verify + // the padding is set correctly if OAEP is disabled + java.lang.reflect.Field builderField = keySpec.getClass().getDeclaredField("mEncryptionPaddings"); + builderField.setAccessible(true); + String[] paddings = (String[]) builderField.get(keySpec); + + assertEquals(1, paddings.length); + assertEquals(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, paddings[0]); + + } catch (Exception e) { + fail("Failed to test getAlgorithmParameterSpec: " + e.getMessage()); + } + } + + @Test + public void testGetLegacyKeyGenParamSpec() { + // Test the legacy key generation parameter spec + try { + java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( + "getLegacyKeyGenParamSpec"); + method.setAccessible(true); + + Object spec = method.invoke(cryptoParameterSpecFactory); + + assertNotNull("Legacy key gen parameter spec should not be null", spec); + assertTrue("Should return a KeyPairGeneratorSpec", + spec instanceof android.security.KeyPairGeneratorSpec); + + android.security.KeyPairGeneratorSpec legacySpec = + (android.security.KeyPairGeneratorSpec) spec; + + // Check key alias is correctly set + java.lang.reflect.Field aliasField = legacySpec.getClass().getDeclaredField("mKeystoreAlias"); + aliasField.setAccessible(true); + String alias = (String) aliasField.get(legacySpec); + + assertEquals(TEST_KEY_ALIAS, alias); + + } catch (Exception e) { + fail("Failed to test getLegacyKeyGenParamSpec: " + e.getMessage()); + } + } + + @Test + public void testKeySize() { + // Test that the default key size is set correctly (2048) + try { + java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( + "getAlgorithmParameterSpec", int.class); + method.setAccessible(true); + + Object spec = method.invoke(cryptoParameterSpecFactory, android.security.keystore.KeyProperties.PURPOSE_ENCRYPT); + + assertNotNull("Algorithm parameter spec should not be null", spec); + assertTrue("Should return a KeyGenParameterSpec", + spec instanceof android.security.keystore.KeyGenParameterSpec); + + android.security.keystore.KeyGenParameterSpec keySpec = + (android.security.keystore.KeyGenParameterSpec) spec; + + // Check key size field + java.lang.reflect.Field keySizeField = keySpec.getClass().getDeclaredField("mKeySize"); + keySizeField.setAccessible(true); + int keySize = (int) keySizeField.get(keySpec); + + assertEquals(2048, keySize); + + } catch (Exception e) { + fail("Failed to test key size: " + e.getMessage()); + } + } + + @Test + public void testOAEPParameterSpec() { + // Specifically test the OAEP spec configuration + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + try { + // Access the oaepSpec field by reflection + java.lang.reflect.Field oaepSpecField = CryptoParameterSpecFactory.class.getDeclaredField("oaepSpec"); + oaepSpecField.setAccessible(true); + + Object oaepSpec = oaepSpecField.get(null); // it's a static field + + assertNotNull("OAEP spec should not be null", oaepSpec); + assertTrue("Should be an OAEPParameterSpec", oaepSpec instanceof OAEPParameterSpec); + + OAEPParameterSpec spec = (OAEPParameterSpec) oaepSpec; + + assertEquals("SHA-256", spec.getDigestAlgorithm()); + assertEquals("MGF1", spec.getMGFAlgorithm()); + assertTrue(spec.getMGFParameters() instanceof MGF1ParameterSpec); + assertEquals(MGF1ParameterSpec.SHA1, spec.getMGFParameters()); + + } catch (Exception e) { + fail("Failed to test OAEP parameter spec: " + e.getMessage()); + } + } + + @Test + public void testWithPurposeWrapKeyOnly() { + // Test with only PURPOSE_WRAP_KEY enabled + newKeyGenSpecWithWrapKeyEnabled = true; + newKeyGenSpecWithoutWrapKeyEnabled = false; + + // Re-create the factory + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + + // Verify we have 2 specs in the right order + assertEquals(2, specs.size()); + assertEquals("modern_spec_with_wrap_key", specs.get(0).getDescription()); + assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index eb09b5b599..53bd6fbb84 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -121,6 +121,11 @@ public enum CommonFlight implements IFlightConfig { */ ENABLE_JS_API_FOR_AUTHUX("EnableJsApiForAuthUx", true), + /** + * Flight to enable the new KEK algorithm for encryption/decryption of keys. + */ + ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING("EnableOAEPWithSHAAndMGF1Padding", true), + /** * Flight to enable the new Android wrapped key loader. */ diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index 5c93535b33..b937d1fecc 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -363,5 +363,10 @@ public enum AttributeName { /** * Records the if the broker handled a switch browser resume, */ - is_switch_browser_resume_handled + is_switch_browser_resume_handled, + + /** + * Records the transformation used to unwrap a key pair. + */ + key_pair_unwrap_transformation, } diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java index b9739b9bfe..7202196749 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java @@ -64,4 +64,5 @@ public enum SpanName { ProcessCrossCloudRedirect, SwitchBrowserResume, SwitchBrowserProcess, + KeyPairUnWrap, } diff --git a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java index 5d0523b095..9ab5b8ddee 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java +++ b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java @@ -22,13 +22,13 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.util; +import com.microsoft.identity.common.java.controllers.ExceptionAdapter; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.logging.Logger; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -146,13 +146,17 @@ public static void deleteFile(@NonNull final File file) { } } - - - - + /** + * Read a string from a file. + * + * @param file the file to read from. + * @return the content of the file as a String, or null if an error occurs. + */ public static String readStringFromFile(File file) { + final String methodTag = TAG + ":readStringFromFile"; + try (FileInputStream fis = new FileInputStream(file); - ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { byte[] buffer = new byte[1024]; int length; @@ -162,27 +166,28 @@ public static String readStringFromFile(File file) { return baos.toString("UTF-8"); } catch (IOException e) { + Logger.error(methodTag, e.getMessage(), e); return null; // or handle the exception as needed } } + + /** + * Write a string to a file. + * + * @param content the string content to write. + * @param file the file to write to. + * @throws ClientException if an error occurs during writing. + */ public static void writeStringToFile(String content, File file) throws ClientException { final String methodTag = TAG + ":writeStringToFile"; try (FileOutputStream fos = new FileOutputStream(file)) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); baos.write(content.getBytes(StandardCharsets.UTF_8)); baos.writeTo(fos); - } catch (IOException e) { - final ClientException clientException = new ClientException( - IO_ERROR, - e.getMessage(), - e - ); - - Logger.error(methodTag, clientException.getErrorCode(), e - ); - throw clientException; + } catch (final IOException e) { + Logger.error(methodTag, e.getMessage(), e); + throw ExceptionAdapter.clientExceptionFromException(e); } } - } From 1961dd73c5f46f946a20cde05a6f25406d09e785 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 7 Jul 2025 12:48:12 -0700 Subject: [PATCH 11/51] fixing merge errors --- .../crypto/AndroidWrappedKeyProviderTest.java | 10 +- .../crypto/AndroidWrappedKeyLoaderFactory.kt | 12 +-- .../crypto/AndroidWrappedKeyProvider.java | 13 +-- ...der.kt => NewAndroidWrappedKeyProvider.kt} | 14 +-- .../internal/util/AndroidKeyStoreUtil.java | 7 +- .../java/crypto/key/ISecretKeyLoader.kt | 99 ------------------- .../crypto/key/PredefinedKeyProvider.java | 6 -- 7 files changed, 24 insertions(+), 137 deletions(-) rename common/src/main/java/com/microsoft/identity/common/crypto/{NewAndroidWrappedKeyLoader.kt => NewAndroidWrappedKeyProvider.kt} (95%) delete mode 100644 common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index 3f8bb1dfd6..929e703fe4 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -314,8 +314,8 @@ public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProv } - private NewAndroidWrappedKeyLoader initKeyLoaderWithKeyEntry() throws ClientException { - final NewAndroidWrappedKeyLoader keyLoader = new NewAndroidWrappedKeyLoader(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + private NewAndroidWrappedKeyProvider initKeyLoaderWithKeyEntry() throws ClientException { + final NewAndroidWrappedKeyProvider keyLoader = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey key = keyLoader.getKey(); Assert.assertNotNull(key); Assert.assertNotNull(keyProvider.getKeyCache().getData()); @@ -323,14 +323,14 @@ private NewAndroidWrappedKeyLoader initKeyLoaderWithKeyEntry() throws ClientExce } /** - * Helper method to generate a random key using NewAndroidWrappedKeyLoader. + * Helper method to generate a random key using NewAndroidWrappedKeyProvider. * This method is used to substitute the call to keyLoader.generateRandomKey() in tests. * - * @param keyLoader The NewAndroidWrappedKeyLoader instance to use + * @param keyLoader The NewAndroidWrappedKeyProvider instance to use * @return The generated SecretKey * @throws ClientException if key generation fails */ - private SecretKey generateRandomKey(NewAndroidWrappedKeyLoader keyLoader) throws ClientException { + private SecretKey generateRandomKey(NewAndroidWrappedKeyProvider keyLoader) throws ClientException { // Get the key will generate a new one if it doesn't exist SecretKey key = keyLoader.getKey(); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt index 805aab53bd..d5f07d7fd4 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt @@ -22,14 +22,14 @@ // THE SOFTWARE. package com.microsoft.identity.common.crypto -import com.microsoft.identity.common.java.crypto.key.ISecretKeyLoader +import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager /** * Factory class for creating wrapped key loaders specific to the Android platform. * - * This object is responsible for creating the appropriate implementation of [ISecretKeyLoader] + * This object is responsible for creating the appropriate implementation of [ISecretKeyProvider] * based on feature flag. It abstracts away the details of which * specific loader implementation should be used, allowing for runtime switching between * different implementations without affecting client code. @@ -47,26 +47,26 @@ object AndroidWrappedKeyLoaderFactory { * @param keyIdentifier A unique identifier for the key being loaded * @param fileName The name of the file where the wrapped key is stored * @param context The Android application context needed for file and security operations - * @return An implementation of [ISecretKeyLoader] that can load the specified wrapped key + * @return An implementation of [ISecretKeyProvider] that can load the specified wrapped key */ fun createWrappedKeyLoader( keyIdentifier: String, fileName: String, context: android.content.Context - ): ISecretKeyLoader { + ): ISecretKeyProvider { val useNewAndroidWrappedKeyLoader = CommonFlightsManager .getFlightsProvider() .isFlightEnabled(CommonFlight.ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER) return if (useNewAndroidWrappedKeyLoader) { - NewAndroidWrappedKeyLoader( + NewAndroidWrappedKeyProvider( keyIdentifier, fileName, context ) } else { - AndroidWrappedKeyLoader( + AndroidWrappedKeyProvider( keyIdentifier, fileName, context diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java index 84f7d1ff68..b9ca03647c 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java @@ -85,15 +85,6 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { private static final String TAG = AndroidWrappedKeyProvider.class.getSimpleName() + "#"; - /** - * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in - * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here - * probably doing PKCS7. We decide to go with Java default string. - */ - private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; - private static final String TAG = AndroidWrappedKeyLoader.class.getSimpleName() + "#"; - - /** * Algorithm for key wrapping. */ @@ -161,7 +152,7 @@ public String getAlias() { @Override @NonNull public String getKeyTypeIdentifier() { - return WRAPPED_KEY_KEY_IDENTIFIER; + return "WRAPPED_KEY_KEY_IDENTIFIER"; } /** @@ -225,7 +216,7 @@ protected SecretKey generateRandomKey() throws ClientException { return null; } - final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, AES_ALGORITHM, keyPair, WRAP_ALGORITHM); + final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, AES_ALGORITHM, keyPair, WRAP_ALGORITHM, null); Logger.info(methodTag, "Key is loaded with thumbprint: " + KeyUtil.getKeyThumbPrint(key)); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.kt b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt similarity index 95% rename from common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt index bd0c6c3e94..036e8ec681 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyLoader.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt @@ -25,7 +25,8 @@ package com.microsoft.identity.common.crypto import android.content.Context import com.microsoft.identity.common.crypto.AndroidWrappedKeyLoaderFactory.WRAPPED_KEY_KEY_IDENTIFIER import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil -import com.microsoft.identity.common.java.crypto.key.AES256KeyLoader +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.KeyUtil import com.microsoft.identity.common.java.exception.ClientException import com.microsoft.identity.common.java.util.CachedData @@ -41,12 +42,12 @@ import javax.crypto.SecretKey * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key * before it get saved to the file. */ -class NewAndroidWrappedKeyLoader @JvmOverloads constructor( +class NewAndroidWrappedKeyProvider @JvmOverloads constructor( override val alias: String, private val mFilePath: String, private val mContext: Context, private val mKekManager: IKekManager = AndroidKeyStoreRsaKekManager(alias, mContext) -) : AES256KeyLoader() { +) : ISecretKeyProvider { // Exposed for testing only. @@ -88,7 +89,8 @@ class NewAndroidWrappedKeyLoader @JvmOverloads constructor( keyCache.data = keyFromStorage return keyFromStorage } - secretKeyGenerator.generateRandomKey().let { newKey -> + + AES256SecretKeyGenerator.generateRandomKey().let { newKey -> Logger.info( methodTag, "New key is generated with thumbprint: " + KeyUtil.getKeyThumbPrint(newKey) @@ -129,7 +131,7 @@ class NewAndroidWrappedKeyLoader @JvmOverloads constructor( methodTag, "Key algorithm file is empty, " + "using SecretKeyGenerator to get the key algorithm" ) - secretKeyGenerator.keyAlgorithm + AES256SecretKeyGenerator.AES_ALGORITHM } val key = mKekManager.unwrapKey(wrappedSecretKey, keyAlgorithm) Logger.info( @@ -210,7 +212,7 @@ class NewAndroidWrappedKeyLoader @JvmOverloads constructor( get() = WRAPPED_KEY_KEY_IDENTIFIER companion object { - private val TAG = NewAndroidWrappedKeyLoader::class.java.simpleName + private val TAG = NewAndroidWrappedKeyProvider::class.java.simpleName // Exposed for testing only. const val KEY_FILE_SIZE: Int = 1024 diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index d57278d3cb..517a46152d 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -423,15 +423,14 @@ public static synchronized SecretKey unwrap(@NonNull final byte[] wrappedKeyBlob final String errCode; try { Log.i(methodTag, "unwrap a key with algorithm: " + wrapAlgorithm); - final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); + //TODO: Once the new KeyProvider is fully implemented, we can remove this suppression. + final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); // CodeQL [SM05136] Used on AndroidWrappedKeyLoader, will be removed once the new KeyProvider is fully implemented. + if (algorithmParameterSpec != null) { wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate(), algorithmParameterSpec); } else { wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate()); } - //TODO: Once the new KeyProvider is fully implemented, we can remove this suppression. - final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); // CodeQL [SM05136] Used on AndroidWrappedKeyLoader, will be removed once the new KeyProvider is fully implemented. - wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate()); return (SecretKey) wrapCipher.unwrap(wrappedKeyBlob, wrappedKeyAlgorithm, Cipher.SECRET_KEY); } catch (final IllegalArgumentException e) { // There is issue with Android KeyStore when lock screen type is changed which could diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt deleted file mode 100644 index 029845cc3a..0000000000 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/ISecretKeyLoader.kt +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.java.crypto.key - -import com.microsoft.identity.common.java.exception.ClientException -import javax.crypto.SecretKey - -/** - * Interface defining how a [SecretKey] is loaded, cached, sourced, and used. - * - * [ISecretKeyLoader] provides a consistent abstraction layer for cryptographic key operations - */ -interface ISecretKeyLoader { - /** - * Returns this key's alias or name. - * - * The alias serves as a unique identifier for this key within the system. - * It can be used for key storage, retrieval, and reference across the application. - * Each key implementation must have a unique alias to avoid collisions. - * - * @return The unique key alias as a non-null String. - */ - val alias: String - - /** - * Gets an identifier of this key type. - * - * The key type identifier is used to distinguish between different key types - * in the system. This value might be padded into encrypted strings to indicate - * the key used for encryption, enabling correct key selection during decryption. - * - * @return The key type identifier as a non-null String. - */ - val keyTypeIdentifier: String - - /** - * Gets the cipher transformation string that is meant to be used with this key type. - * - * A cipher transformation string consists of three components: - * - Algorithm: The base cryptographic algorithm (e.g., "AES", "RSA") - * - Mode of operation: How the algorithm should process the data (e.g., "CBC", "GCM", "ECB") - * - Padding scheme: How to handle data that doesn't align with the block size (e.g., "PKCS5Padding", "NoPadding") - * - * For example, "AES/CBC/PKCS5Padding" specifies the AES algorithm in CBC mode with PKCS5 padding. - * The transformation specified must be compatible with the generated keys and supported by the - * security provider being used. - * - * This transformation string is used directly with [javax.crypto.Cipher.getInstance] to create - * the appropriate Cipher object for encryption and decryption operations. - * - * @return The complete cipher transformation string as a non-null String. - */ - val cipherTransformation: String - - /** - * The [ISecretKeyGenerator] used to generate new keys when needed. - * - * This generator is responsible for creating cryptographically secure keys - * using the appropriate algorithm and key size for this key loader implementation. - * - * @return The secret key generator for this key type. - */ - val secretKeyGenerator: ISecretKeyGenerator - - /** - * Retrieves the secret key for encryption/decryption operations. - * - * This method handles the loading of an existing key or generation of a new key - * if one doesn't exist. Key storage, caching, and platform-specific logic - * should be encapsulated within implementations of this property. - * - * @return The [SecretKey] to be used for cryptographic operations. - * @throws ClientException If an error occurs during key retrieval or generation, - * including key store access issues or algorithm unavailability. - */ - @get:Throws(ClientException::class) - val key: SecretKey -} - diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java index d6b326d570..6b9ed2c238 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java @@ -85,10 +85,4 @@ public SecretKey getKey() { public String getCipherTransformation() { return CIPHER_TRANSFORMATION; } - - @Override - @NonNull - public String getCipherTransformation(){ - return CIPHER_ALGORITHM; - } } From 6e9717da26f4c3098fbc97cee9ec09b15f69fb35 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 7 Jul 2025 13:01:33 -0700 Subject: [PATCH 12/51] Enhance AndroidWrappedKeyProvider and CipherSpec with additional constants and documentation; refine StorageEncryptionManager logic; clean up PredefinedKeyProvider and MockAES256KeyProvider imports. --- .../crypto/AndroidWrappedKeyProvider.java | 15 ++++++++-- .../identity/common/crypto/CipherSpec.kt | 28 +++++++++++++++++++ .../java/crypto/StorageEncryptionManager.java | 2 +- .../crypto/key/PredefinedKeyProvider.java | 7 ----- .../java/crypto/MockAES256KeyProvider.java | 1 + 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java index b9ca03647c..05ca411de5 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java @@ -85,6 +85,12 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { private static final String TAG = AndroidWrappedKeyProvider.class.getSimpleName() + "#"; + /** + * Should KeyStore and key file check for validity before every key load be skipped. + */ + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + public static boolean sSkipKeyInvalidationCheck = false; + /** * Algorithm for key wrapping. */ @@ -95,6 +101,11 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { */ private static final String WRAP_KEY_ALGORITHM = "RSA"; + /** + * Indicate that token item is encrypted with the key loaded in this class. + */ + public static final String WRAPPED_KEY_KEY_IDENTIFIER = "A001"; + // Exposed for testing only. /* package */ static final int KEY_FILE_SIZE = 1024; @@ -114,7 +125,7 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { private final CachedData mKeyCache = new CachedData() { @Override public SecretKey getData() { - if (!AndroidWrappedKeyLoaderFactory.INSTANCE.getSkipKeyInvalidationCheck() && + if (!sSkipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !getKeyFile().exists())) { this.clear(); } @@ -152,7 +163,7 @@ public String getAlias() { @Override @NonNull public String getKeyTypeIdentifier() { - return "WRAPPED_KEY_KEY_IDENTIFIER"; + return WRAPPED_KEY_KEY_IDENTIFIER; } /** diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt index 789a08f130..ce8763acfd 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt @@ -1,7 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. package com.microsoft.identity.common.crypto import java.security.spec.AlgorithmParameterSpec +/** + * Represents a cipher specification that includes the algorithm parameters and transformation. + * + * @property algorithmParameterSpecs The algorithm parameter specifications, or null if not applicable. + * @property transformation The transformation string for the cipher. + */ data class CipherSpec( val algorithmParameterSpecs: AlgorithmParameterSpec?, val transformation: String diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java index ce09af0558..8e1f7ae954 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/StorageEncryptionManager.java @@ -478,7 +478,7 @@ private void assertHMac(final byte[] encryptedBlob, // It does not fail fast on the first not equal byte to protect against // timing attack. for (int i = start; i < end; i++) { - result |= (byte) (expected[i - start] ^ encryptedBlob[i]); + result |= expected[i - start] ^ encryptedBlob[i]; } if (result != 0) { diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java index 6b9ed2c238..5b936c04fb 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/key/PredefinedKeyProvider.java @@ -41,13 +41,6 @@ public class PredefinedKeyProvider implements ISecretKeyProvider { */ private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; - /** - * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in - * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here - * probably doing PKCS7. We decide to go with Java default string. - */ - private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; - /** * Indicate that the token item is encrypted with the user provided key. */ diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyProvider.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyProvider.java index fc6dcd9218..b0b90b6b33 100644 --- a/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyProvider.java +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/MockAES256KeyProvider.java @@ -26,6 +26,7 @@ import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider; import com.microsoft.identity.common.java.exception.ClientException; + import org.jetbrains.annotations.NotNull; import javax.crypto.SecretKey; From 15382fa5eea19468dbfc6602304b6ea0d6422e9c Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 8 Jul 2025 12:33:42 -0700 Subject: [PATCH 13/51] Refactor AndroidKeyStoreRsaKekManager for improved key management; remove CipherSpec and KeyGenSpec classes; enhance CryptoParameterSpecFactory with updated parameter specifications. --- .../crypto/AndroidKeyStoreRsaKekManager.kt | 224 +++++------------- .../crypto/CryptoParameterSpecFactory.kt | 72 +++--- .../crypto/{CipherSpec.kt => CryptoSpecs.kt} | 30 ++- .../identity/common/crypto/KeyGenSpec.kt | 8 - 4 files changed, 129 insertions(+), 205 deletions(-) rename common/src/main/java/com/microsoft/identity/common/crypto/{CipherSpec.kt => CryptoSpecs.kt} (62%) delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt index 0a954268b1..27afc3b377 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt @@ -36,6 +36,7 @@ import java.security.KeyPair import java.security.spec.AlgorithmParameterSpec import javax.crypto.SecretKey + /** * Implementation of [IKekManager] that uses Android KeyStore system with RSA key pairs * for key encryption key (KEK) management. @@ -53,33 +54,16 @@ import javax.crypto.SecretKey class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( private val keyAlias: String, context: Context, - private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory( - context, - keyAlias - ) + private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = + CryptoParameterSpecFactory(context, keyAlias) ) : IKekManager { companion object { private val TAG: String = AndroidKeyStoreRsaKekManager::class.java.simpleName - /** - * Algorithm used to generate the RSA wrapping key. - * RSA is used for asymmetric key wrapping operations where the public key - * encrypts (wraps) the data key and the private key decrypts (unwraps) it. - */ - private const val KEK_ALGORITHM = "RSA" } - /** - * Returns the primary cipher transformation to be used for cryptographic operations. - * - * - * The transformation string specifies the algorithm, mode, and padding in the format - * "algorithm/mode/padding" (e.g., "RSA/ECB/PKCS1Padding"). - * - * @return The cipher transformation string for cryptographic operations - */ - override val cipherTransformation: String = getMainCipherSpec().transformation + /** * Checks if a key encryption key (KEK) exists in the Android KeyStore for the specified alias. @@ -111,13 +95,7 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( override fun unwrapKey(wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey { val methodTag = "$TAG:unwrapKey" - val span = OTelUtility.createSpanFromParent( - SpanName.KeyPairUnWrap.name, - SpanExtension.current().spanContext - ) - // Attempt to read the key pair from the Android KeyStore val keyPair = AndroidKeyStoreUtil.readKey(keyAlias) ?: run { - // If no key pair exists, we can't proceed with unwrapping val error = ClientException( ClientException.KEY_LOAD_FAILURE, "No existing keypair found for alias: $keyAlias" @@ -125,50 +103,19 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( Logger.error(methodTag, error.message, error) throw error } - // If we have a key pair, proceed to unwrap the key using available cipher specs - val specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() - val failures = mutableListOf() - try { - SpanExtension.makeCurrentSpan(span).use { _ -> - // Try each spec in order of priority - for ((params, transformation) in specs) { - try { - // Attempt to unwrap the key using the current spec - val unwrappedKey = AndroidKeyStoreUtil.unwrap( - wrappedSecretKey, - secretKeyAlgorithm, - keyPair, - transformation, - params - ) - span.setAttribute( - AttributeName.key_pair_unwrap_transformation.name, - transformation - ) - span.setStatus(StatusCode.OK) - return unwrappedKey - } catch (throwable: Throwable) { - // Continue to the next spec if this one fails - Logger.warn(methodTag, "Failed to unwrap key with spec: $transformation") - failures.add(throwable) - } - } - // If we reach here, all attempts to unwrap the key have failed - failures.forEach { exception -> - Logger.error(methodTag, "Unwrap failure with: ${exception.message}", exception) - } - val finalError = failures.lastOrNull() ?: ClientException( - ClientException.UNKNOWN_CRYPTO_ERROR, - "No specs available to attempt key unwrapping." + return executeWithFallbacks( + specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(), + spanName = SpanName.KeyPairUnWrap.name, + operation = { cipherParameterSpec -> + AndroidKeyStoreUtil.unwrap( + wrappedSecretKey, + secretKeyAlgorithm, + keyPair, + cipherParameterSpec.transformation, + cipherParameterSpec.algorithmParameterSpec ) - span.setStatus(StatusCode.ERROR) - span.recordException(finalError) - throw ExceptionAdapter.clientExceptionFromException(finalError) - } - } finally { - span.end() // Span is ended only once, after all attempts - } + ) } /** @@ -190,12 +137,12 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( Logger.info(methodTag, "No existing keypair found for alias. Generating a new keypair.") keyPair = generateKeyPair() } - + val cipherParamsSpec = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs().first() return AndroidKeyStoreUtil.wrap( keyToWrap, keyPair, - getMainCipherSpec().transformation, - getMainCipherSpec().algorithmParameterSpecs + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec ) } @@ -215,116 +162,75 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( */ @Throws(ClientException::class) private fun generateKeyPair(): KeyPair { - val methodTag = "$TAG:generateKeyPair" - val span = OTelUtility.createSpanFromParent( - SpanName.KeyPairGeneration.name, - SpanExtension.current().spanContext + return executeWithFallbacks( + specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(), + spanName = SpanName.KeyPairGeneration.name, + operation = { keyGenParameterSpec -> + val keypairGenStartTime = System.currentTimeMillis() + val keyPair = AndroidKeyStoreUtil.generateKeyPair( + keyGenParameterSpec.algorithm, + keyGenParameterSpec.algorithmParameterSpec + ) + val elapsedTime = System.currentTimeMillis() - keypairGenStartTime + SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) + return@executeWithFallbacks keyPair + } ) + } - val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() + /** + * Executes a cryptographic operation with a fallback mechanism, iterating through a list of + * specifications until the operation succeeds. + * + * This generic function is designed to handle operations that might fail with certain + * configurations, providing resilience by attempting multiple alternatives. It also integrates + * with OpenTelemetry to trace the execution and log relevant information for monitoring. + * + * @param T The type of the specification object. + * @param R The return type of the cryptographic operation. + * @param specs A list of specifications to try in order. + * @param spanName The name for the OpenTelemetry span that will trace the operation. + * @param operation A lambda function that takes a spec and performs the cryptographic operation, returning the result. + * @return The result of the successful cryptographic operation. + * @throws ClientException if all attempts fail. + */ + private fun executeWithFallbacks( + specs: List, + spanName: String, + operation: (T) -> R + ): R { + val methodTag = "$TAG:executeWithFallbacks" + val span = OTelUtility.createSpanFromParent(spanName, SpanExtension.current().spanContext) val failures = mutableListOf() + try { SpanExtension.makeCurrentSpan(span).use { _ -> - // Try each spec in order of priority - for ((keyGenParameterSpec, description) in specs) { + for (spec in specs) { try { - val keyPair = attemptKeyPairGeneration(keyGenParameterSpec) - span.setAttribute( - AttributeName.key_pair_gen_successful_method.name, - description - ) - Logger.info( - methodTag, - "Successfully generated key pair using: $description" - ) + val result = operation(spec) span.setStatus(StatusCode.OK) - return keyPair + //Logger.info(methodTag, "Successfully executed ${spanName} with spec: ${spec.getDescription()}") + return result } catch (throwable: Throwable) { - // Log the failure but continue to the next spec - Logger.warn(methodTag, "Failed to generate keypair with spec: $description") - throwable.message?.let { - span.setAttribute(AttributeName.keypair_gen_exception.name, it) - } + Logger.warn(methodTag, "Failed to execute $spanName with") failures.add(throwable) } } - // If we reach here, all attempts to unwrap the key have failed + // If we reach here, all attempts have failed failures.forEach { exception -> - Logger.error( - methodTag, - "KeyPair generation fail with: ${exception.message}", - exception - ) + Logger.error(methodTag, "Operation failed with: ${exception.message}", exception) } val finalError = failures.lastOrNull() ?: ClientException( ClientException.UNKNOWN_CRYPTO_ERROR, - "Failed to generate key pair after trying all available specs." + "Operation failed after trying all available specs." ) span.setStatus(StatusCode.ERROR) span.recordException(finalError) throw ExceptionAdapter.clientExceptionFromException(finalError) } } finally { - span.end() // Span is ended only once, after all attempts - } - } - - /** - * Attempts to generate a key pair using the provided algorithm parameter specification. - * - * - * This method records the time taken for key generation for performance monitoring. - * - * @param keyPairGenSpec The algorithm parameter specification for key generation - * @return The generated KeyPair - * @throws ClientException if key generation fails - */ - @Throws(ClientException::class) - private fun attemptKeyPairGeneration(keyPairGenSpec: AlgorithmParameterSpec): KeyPair { - val keypairGenStartTime = System.currentTimeMillis() - val keyPair = AndroidKeyStoreUtil.generateKeyPair(KEK_ALGORITHM, keyPairGenSpec) - recordKeyGenerationTime(keypairGenStartTime) - return keyPair - } - - /** - * Records the elapsed time for key pair generation in the current span - * for performance monitoring and diagnostics. - * - * @param keypairGenStartTime The timestamp when the key generation process started - */ - private fun recordKeyGenerationTime(keypairGenStartTime: Long) { - val elapsedTime = System.currentTimeMillis() - keypairGenStartTime - SpanExtension.current() - .setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) - } - - /** - * Retrieves the main cipher specification to be used for cryptographic operations. - * - * This method returns the highest priority cipher specification from the available - * specifications provided by the crypto parameter spec factory. The main cipher spec - * is used for key wrapping operations. - * - * @return The highest priority CipherSpec to use for operations - * @throws ClientException if no cipher specifications are available - */ - private fun getMainCipherSpec(): CipherSpec { - val methodTag = "$TAG:getMainCipherSpec" - val specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() - if (specs.isEmpty()) { - // This should never happen, there should always be at least one spec - Logger.error( - methodTag, - "No cipher specifications available for key wrapping.", - null - ) - throw ClientException( - ClientException.NO_SUCH_ALGORITHM, - "No cipher specifications available for key wrapping." - ) + span.end() } - return specs[0] } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index dfff12aba9..65961dc171 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -69,8 +69,9 @@ import javax.security.auth.x500.X500Principal * This factory helps navigate these limitations by providing appropriate fallback mechanisms. */ class CryptoParameterSpecFactory( - private val context: Context, private val keyAlias: String, - private val flightsProvider: IFlightsProvider = getFlightsProvider() + private val context: Context, + private val keyAlias: String, + flightsProvider: IFlightsProvider = getFlightsProvider() ) { private companion object { @@ -81,6 +82,7 @@ class CryptoParameterSpecFactory( private const val LEGACY_SPEC = "legacy_key_gen_spec" private const val OAEP_TRANSFORMATION = "RSA/NONE/OAEPwithSHA-256andMGF1Padding" private const val PKCS1_TRANSFORMATION = "RSA/ECB/PKCS1Padding" + private const val RSA_ALGORITHM = "RSA" private val OAEP_SPECS = OAEPParameterSpec( "SHA-256", // main digest "MGF1", // mask generation function @@ -97,40 +99,44 @@ class CryptoParameterSpecFactory( private val keySpecWithOAEP = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) + // Cipher parameter specifications - private val legacyCipherSpec = CipherSpec( - algorithmParameterSpecs = null, - transformation = PKCS1_TRANSFORMATION + private val pkcs1CipherSpec = CipherSpec( + algorithmParameterSpec = null, + transformation = PKCS1_TRANSFORMATION, ) private val oaepCipherSpec = CipherSpec( - algorithmParameterSpecs = OAEP_SPECS, - transformation = OAEP_TRANSFORMATION + algorithmParameterSpec = OAEP_SPECS, + transformation = OAEP_TRANSFORMATION, ) // Key generation parameter specifications @RequiresApi(Build.VERSION_CODES.P) private val keyGenParamSpecWithPurposeWrapKey = KeyGenSpec( - keyGenParameterSpec = getAlgorithmParameterSpec( + algorithmParameterSpec = createKeyGenParameterSpec( KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_WRAP_KEY ), - description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY + description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY, + algorithm = RSA_ALGORITHM ) @RequiresApi(Build.VERSION_CODES.M) private val keyGenParamSpecWithoutPurposeWrapKey = KeyGenSpec( - keyGenParameterSpec = getAlgorithmParameterSpec( + algorithmParameterSpec = createKeyGenParameterSpec( KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ), - description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY + description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY, + algorithm = RSA_ALGORITHM ) private val keyGenParamSpecLegacy = KeyGenSpec( - keyGenParameterSpec = getLegacyKeyGenParamSpec(), - description = LEGACY_SPEC + algorithmParameterSpec = getLegacyKeyGenParamSpec(), + description = LEGACY_SPEC, + algorithm = RSA_ALGORITHM ) /** @@ -174,24 +180,26 @@ class CryptoParameterSpecFactory( * @return A [KeyGenParameterSpec] configured according to current settings */ @RequiresApi(api = Build.VERSION_CODES.M) - private fun getAlgorithmParameterSpec(purposes: Int): AlgorithmParameterSpec { - val methodTag = "$TAG:getSpecForWrappingKey" - return if (keySpecWithOAEP) { - Logger.info(methodTag, "Using OAEP padding with SHA-256 digest") - KeyGenParameterSpec.Builder(keyAlias, purposes) - .setKeySize(KEY_SIZE) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) - .build() - } else { - // Fallback to legacy spec if OAEP is not enabled. - Logger.info(methodTag, "Using PKCS1 padding with SHA-256 and SHA-512 digests") - KeyGenParameterSpec.Builder(keyAlias, purposes) - .setKeySize(KEY_SIZE) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) - .build() - } + private fun createKeyGenParameterSpec(purposes: Int): AlgorithmParameterSpec { + val methodTag = "$TAG:createKeyGenParameterSpec" + + val builder = KeyGenParameterSpec.Builder(keyAlias, purposes) + .setKeySize(KEY_SIZE) + + return when { + keySpecWithOAEP -> { + Logger.info(methodTag, "Using OAEP padding with SHA-256 and SHA-1 digests") + builder + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + } + else -> { + Logger.info(methodTag, "Using PKCS1 padding with SHA-256 and SHA-512 digests") + builder + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + } + }.build() } init { @@ -223,7 +231,7 @@ class CryptoParameterSpecFactory( specs.add(oaepCipherSpec) } // Always include legacy PKCS1 padding as a fallback for compatibility - specs.add(legacyCipherSpec) + specs.add(pkcs1CipherSpec) Logger.info(methodTag, "Options: ${specs.joinToString { it.transformation }}") return specs } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt similarity index 62% rename from common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt index ce8763acfd..6c42b63dcb 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt @@ -24,13 +24,31 @@ package com.microsoft.identity.common.crypto import java.security.spec.AlgorithmParameterSpec + +interface CryptoSpec{ + val algorithmParameterSpec: AlgorithmParameterSpec? +} + /** - * Represents a cipher specification that includes the algorithm parameters and transformation. + * Data class to hold cipher parameter specifications. * - * @property algorithmParameterSpecs The algorithm parameter specifications, or null if not applicable. - * @property transformation The transformation string for the cipher. + * @property algorithmParameterSpec The algorithm parameter specification (can be null). + * @property transformation The transformation string (e.g., "RSA/ECB/PKCS1Padding"). */ data class CipherSpec( - val algorithmParameterSpecs: AlgorithmParameterSpec?, - val transformation: String -) + override val algorithmParameterSpec: AlgorithmParameterSpec?, + val transformation: String, +) : CryptoSpec + +/** + * Data class to hold key generation parameter specifications. + * + * @property algorithmParameterSpec The key generation parameter specification. + * @property description A descriptive string for the specification. + */ +data class KeyGenSpec( + val description: String, + val algorithm: String, + override val algorithmParameterSpec: AlgorithmParameterSpec +) : CryptoSpec + diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt deleted file mode 100644 index 91aa733110..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.microsoft.identity.common.crypto - -import java.security.spec.AlgorithmParameterSpec - -data class KeyGenSpec( - val keyGenParameterSpec: AlgorithmParameterSpec, - val description: String -) \ No newline at end of file From ca22c2aa5c2ef66638777b50d7c809a436150ad3 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 9 Jul 2025 17:05:50 -0700 Subject: [PATCH 14/51] Refactor key management in AndroidKeyStoreRsaKekManager and related classes; update unwrapping logic, enhance cipher specifications, and improve key retrieval methods. --- .../crypto/AndroidWrappedKeyProviderTest.java | 15 +- .../crypto/AndroidKeyStoreRsaKekManager.kt | 146 ++++++++---------- .../crypto/CryptoParameterSpecFactory.kt | 69 +++++---- .../identity/common/crypto/CryptoSpecs.kt | 21 ++- .../identity/common/crypto/IKekManager.kt | 21 +-- .../crypto/NewAndroidWrappedKeyProvider.kt | 18 +-- .../internal/util/AndroidKeyStoreUtil.java | 53 +++++++ 7 files changed, 194 insertions(+), 149 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index 929e703fe4..5a934e3880 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -31,6 +31,7 @@ import com.microsoft.identity.common.adal.internal.AuthenticationSettings; import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.util.FileUtil; @@ -132,7 +133,7 @@ public void testGenerateKey() throws ClientException { @Test public void testReadKeyDirectly() throws ClientException { - final AndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); final SecretKey secretKey = keyProvider.getKey(); final SecretKey storedSecretKey = keyProvider.readSecretKeyFromStorage(); @@ -254,7 +255,7 @@ public void testPerf_NoCachedKey() throws ClientException { */ @Test public void testLoadDeletedKeyStoreKey() throws ClientException { - final AndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); @@ -265,7 +266,7 @@ public void testLoadDeletedKeyStoreKey() throws ClientException { @Test public void testLoadDeletedKeyFile() throws ClientException { - final AndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); FileUtil.deleteFile(getKeyFile()); @@ -291,7 +292,7 @@ public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProv KeyPair keyPair = keyPairGenerator.generateKeyPair(); // Assume AES key was created elsewhere - SecretKey aesKey = new AES256SecretKeyGenerator().generateRandomKey(); + SecretKey aesKey = AES256SecretKeyGenerator.INSTANCE.generateRandomKey(); // Use OAEPParameterSpec with SHA-256 as main digest but SHA-1 for MGF1 OAEPParameterSpec oaepParams = new OAEPParameterSpec( @@ -314,9 +315,9 @@ public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProv } - private NewAndroidWrappedKeyProvider initKeyLoaderWithKeyEntry() throws ClientException { - final NewAndroidWrappedKeyProvider keyLoader = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - final SecretKey key = keyLoader.getKey(); + private NewAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws ClientException { + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final SecretKey key = keyProvider.getKey(); Assert.assertNotNull(key); Assert.assertNotNull(keyProvider.getKeyCache().getData()); return keyProvider; diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt index 27afc3b377..72eba0fe91 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt @@ -23,6 +23,7 @@ package com.microsoft.identity.common.crypto import android.content.Context +import android.util.Log import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil import com.microsoft.identity.common.java.controllers.ExceptionAdapter import com.microsoft.identity.common.java.exception.ClientException @@ -33,7 +34,6 @@ import com.microsoft.identity.common.java.opentelemetry.SpanName import com.microsoft.identity.common.logging.Logger import io.opentelemetry.api.trace.StatusCode import java.security.KeyPair -import java.security.spec.AlgorithmParameterSpec import javax.crypto.SecretKey @@ -63,19 +63,6 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( } - - - /** - * Checks if a key encryption key (KEK) exists in the Android KeyStore for the specified alias. - * - * @return true if the key pair exists and is accessible, false otherwise - * @throws ClientException if there's an error accessing the Android KeyStore - */ - @Throws(ClientException::class) - override fun kekExists(): Boolean { - return AndroidKeyStoreUtil.readKey(keyAlias) != null - } - /** * Unwraps (decrypts) a previously wrapped secret key using the RSA private key * from the Android KeyStore. @@ -92,32 +79,19 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( * invalid wrapped key data, or unsupported cipher specifications */ @Throws(ClientException::class) - override fun unwrapKey(wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey { - val methodTag = "$TAG:unwrapKey" - - val keyPair = AndroidKeyStoreUtil.readKey(keyAlias) ?: run { - val error = ClientException( - ClientException.KEY_LOAD_FAILURE, - "No existing keypair found for alias: $keyAlias" - ) - Logger.error(methodTag, error.message, error) - throw error - } - return executeWithFallbacks( - specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(), - spanName = SpanName.KeyPairUnWrap.name, - operation = { cipherParameterSpec -> - AndroidKeyStoreUtil.unwrap( - wrappedSecretKey, - secretKeyAlgorithm, - keyPair, - cipherParameterSpec.transformation, - cipherParameterSpec.algorithmParameterSpec - ) - } + override fun unwrapKey(keyPair: KeyPair, wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey { + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + Log.i(TAG, "Unwrapping key with CipherSpec: $cipherParamsSpec") + return AndroidKeyStoreUtil.unwrap( + wrappedSecretKey, + secretKeyAlgorithm, + keyPair, + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec ) } + /** * Wraps (encrypts) a secret key using the RSA public key from the Android KeyStore. * @@ -137,7 +111,8 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( Logger.info(methodTag, "No existing keypair found for alias. Generating a new keypair.") keyPair = generateKeyPair() } - val cipherParamsSpec = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs().first() + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + Log.i(TAG, "Wrapping key with cipher: $cipherParamsSpec") return AndroidKeyStoreUtil.wrap( keyToWrap, keyPair, @@ -146,6 +121,41 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( ) } + /** + * Selects the most appropriate [CipherSpec] for the given [KeyPair] by matching the supported + * encryption paddings from the Android Keystore with a prioritized list of available cipher specs. + * + * This function attempts to find a compatible cipher configuration for key wrapping by: + * 1. Fetching the encryption paddings supported by the provided [keyPair]. + * 2. Iterating through the prioritized list of [CipherSpec]s. + * 3. Returning the first compatible spec where the padding is supported by the key. + * + * If no matching specification is found, a fallback using PKCS#1 padding is returned. + * + * @param keyPair The [KeyPair] for which a compatible [CipherSpec] should be determined. + * @return A compatible [CipherSpec], or a fallback to a PKCS#1-based spec if none are supported. + */ + private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { + val methodTag = "$TAG:selectCompatibleCipherSpec" + val supportedPaddings = AndroidKeyStoreUtil.getEncryptionPaddings(keyPair) + val availableSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() + Log.i( + TAG, + "Supported paddings by the keyPair: $supportedPaddings" + + ",Specs available in order of priority: $availableSpecs" + ) + for (spec in availableSpecs) { + for (padding in supportedPaddings) { + if (spec.padding.contains(padding, ignoreCase = true)) { + return spec + } + } + } + Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") + // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. + return cryptoParameterSpecFactory.getPkcs1CipherSpec() + } + /** * Generates a new RSA key pair and stores it in the Android KeyStore. * @@ -162,68 +172,38 @@ class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( */ @Throws(ClientException::class) private fun generateKeyPair(): KeyPair { - return executeWithFallbacks( - specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(), - spanName = SpanName.KeyPairGeneration.name, - operation = { keyGenParameterSpec -> - val keypairGenStartTime = System.currentTimeMillis() - val keyPair = AndroidKeyStoreUtil.generateKeyPair( - keyGenParameterSpec.algorithm, - keyGenParameterSpec.algorithmParameterSpec - ) - val elapsedTime = System.currentTimeMillis() - keypairGenStartTime - SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) - return@executeWithFallbacks keyPair - } - ) - } - - /** - * Executes a cryptographic operation with a fallback mechanism, iterating through a list of - * specifications until the operation succeeds. - * - * This generic function is designed to handle operations that might fail with certain - * configurations, providing resilience by attempting multiple alternatives. It also integrates - * with OpenTelemetry to trace the execution and log relevant information for monitoring. - * - * @param T The type of the specification object. - * @param R The return type of the cryptographic operation. - * @param specs A list of specifications to try in order. - * @param spanName The name for the OpenTelemetry span that will trace the operation. - * @param operation A lambda function that takes a spec and performs the cryptographic operation, returning the result. - * @return The result of the successful cryptographic operation. - * @throws ClientException if all attempts fail. - */ - private fun executeWithFallbacks( - specs: List, - spanName: String, - operation: (T) -> R - ): R { - val methodTag = "$TAG:executeWithFallbacks" - val span = OTelUtility.createSpanFromParent(spanName, SpanExtension.current().spanContext) + val methodTag = "$TAG:generateKeyPair" + val span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name, SpanExtension.current().spanContext) val failures = mutableListOf() + val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() try { SpanExtension.makeCurrentSpan(span).use { _ -> for (spec in specs) { try { - val result = operation(spec) + val keypairGenStartTime = System.currentTimeMillis() + val keyPair = AndroidKeyStoreUtil.generateKeyPair( + spec.algorithm, + spec.algorithmParameterSpec + ) + val elapsedTime = System.currentTimeMillis() - keypairGenStartTime + SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) span.setStatus(StatusCode.OK) - //Logger.info(methodTag, "Successfully executed ${spanName} with spec: ${spec.getDescription()}") - return result + Log.i(TAG, "Key pair generated successfully with spec: $spec ") + return keyPair } catch (throwable: Throwable) { - Logger.warn(methodTag, "Failed to execute $spanName with") + Logger.warn(methodTag, "Failed to generate key pair with spec: $spec") failures.add(throwable) } } // If we reach here, all attempts have failed failures.forEach { exception -> - Logger.error(methodTag, "Operation failed with: ${exception.message}", exception) + Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) } val finalError = failures.lastOrNull() ?: ClientException( ClientException.UNKNOWN_CRYPTO_ERROR, - "Operation failed after trying all available specs." + "Key pair generation failed after trying all available specs." ) span.setStatus(StatusCode.ERROR) span.recordException(finalError) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index 65961dc171..0803e35fe7 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -76,13 +76,20 @@ class CryptoParameterSpecFactory( private companion object { private val TAG = CryptoParameterSpecFactory::class.java.simpleName + // Algorithm used for RSA key generation and encryption + private const val RSA_ALGORITHM = "RSA" + // Default key size for RSA keys private const val KEY_SIZE: Int = 2048 + // Descriptive identifiers for different key generation specifications private const val MODERN_SPEC_WITH_PURPOSE_WRAP_KEY = "modern_spec_with_wrap_key" private const val MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY = "modern_spec_without_wrap_key" private const val LEGACY_SPEC = "legacy_key_gen_spec" - private const val OAEP_TRANSFORMATION = "RSA/NONE/OAEPwithSHA-256andMGF1Padding" - private const val PKCS1_TRANSFORMATION = "RSA/ECB/PKCS1Padding" - private const val RSA_ALGORITHM = "RSA" + // Padding schemes and modes used in cipher operations + private const val PKCS1_PADDING = "PKCS1Padding" + private const val OAEP_PADDING_WITH_256MGF1 = "OAEPWithSHA-256AndMGF1Padding" + private const val MODE_ECB = "ECB" + private const val MODE_NONE = "NONE" + // OAEP parameter specification for RSA encryption private val OAEP_SPECS = OAEPParameterSpec( "SHA-256", // main digest "MGF1", // mask generation function @@ -96,19 +103,23 @@ class CryptoParameterSpecFactory( flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) private val keySpecWithoutPurposeKey = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - private val keySpecWithOAEP = + private val supportsEncryptionPaddingRsaOaep = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) // Cipher parameter specifications private val pkcs1CipherSpec = CipherSpec( algorithmParameterSpec = null, - transformation = PKCS1_TRANSFORMATION, + algorithm = RSA_ALGORITHM, + mode = MODE_ECB, + padding = PKCS1_PADDING ) private val oaepCipherSpec = CipherSpec( algorithmParameterSpec = OAEP_SPECS, - transformation = OAEP_TRANSFORMATION, + algorithm = RSA_ALGORITHM, + mode = MODE_NONE, + padding = OAEP_PADDING_WITH_256MGF1, ) // Key generation parameter specifications @@ -120,6 +131,7 @@ class CryptoParameterSpecFactory( KeyProperties.PURPOSE_WRAP_KEY ), description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY, + encryptionPadding = getEncryptionPaddings(), algorithm = RSA_ALGORITHM ) @@ -130,12 +142,14 @@ class CryptoParameterSpecFactory( KeyProperties.PURPOSE_DECRYPT ), description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY, + encryptionPadding = getEncryptionPaddings(), algorithm = RSA_ALGORITHM ) private val keyGenParamSpecLegacy = KeyGenSpec( algorithmParameterSpec = getLegacyKeyGenParamSpec(), description = LEGACY_SPEC, + encryptionPadding = PKCS1_PADDING, algorithm = RSA_ALGORITHM ) @@ -181,25 +195,11 @@ class CryptoParameterSpecFactory( */ @RequiresApi(api = Build.VERSION_CODES.M) private fun createKeyGenParameterSpec(purposes: Int): AlgorithmParameterSpec { - val methodTag = "$TAG:createKeyGenParameterSpec" - - val builder = KeyGenParameterSpec.Builder(keyAlias, purposes) + return KeyGenParameterSpec.Builder(keyAlias, purposes) .setKeySize(KEY_SIZE) - - return when { - keySpecWithOAEP -> { - Logger.info(methodTag, "Using OAEP padding with SHA-256 and SHA-1 digests") - builder - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) - } - else -> { - Logger.info(methodTag, "Using PKCS1 padding with SHA-256 and SHA-512 digests") - builder - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) - } - }.build() + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(getEncryptionPaddings()) + .build() } init { @@ -209,10 +209,19 @@ class CryptoParameterSpecFactory( "Initialized with keyAlias: $keyAlias, API level: ${Build.VERSION.SDK_INT}, " + "With flight flags - PurposeWrapKey: $keySpecWithPurposeKey, " + "WithoutPurposeKey: $keySpecWithoutPurposeKey, " + - "WithOAEP: $keySpecWithOAEP" + "WithOAEP: $supportsEncryptionPaddingRsaOaep" ) } + @RequiresApi(Build.VERSION_CODES.M) + private fun getEncryptionPaddings(): String { + return if (supportsEncryptionPaddingRsaOaep) { + KeyProperties.ENCRYPTION_PADDING_RSA_OAEP + } else { + KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1 + } + } + /** * Returns a prioritized list of cipher parameter specifications to try in sequence. * @@ -227,7 +236,7 @@ class CryptoParameterSpecFactory( val methodTag = "$TAG:getPrioritizedCipherParameterSpecs" val specs = mutableListOf() // Add OAEP padding spec first (if enabled) as it provides stronger security - if (keySpecWithOAEP) { + if (supportsEncryptionPaddingRsaOaep) { specs.add(oaepCipherSpec) } // Always include legacy PKCS1 padding as a fallback for compatibility @@ -274,4 +283,12 @@ class CryptoParameterSpecFactory( Logger.info(methodTag, "Options: ${specs.joinToString { it.description }}") return specs } + + /** + * Returns the PKCS1 cipher specification. + * + */ + fun getPkcs1CipherSpec(): CipherSpec { + return pkcs1CipherSpec + } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt index 6c42b63dcb..7d5204ba73 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt @@ -37,8 +37,16 @@ interface CryptoSpec{ */ data class CipherSpec( override val algorithmParameterSpec: AlgorithmParameterSpec?, - val transformation: String, -) : CryptoSpec + val algorithm: String, + val mode: String, + val padding: String, +) : CryptoSpec { + val transformation = "$algorithm/$mode/$padding" + + override fun toString(): String { + return "CipherSpec(transformation='$transformation')" + } +} /** * Data class to hold key generation parameter specifications. @@ -49,6 +57,11 @@ data class CipherSpec( data class KeyGenSpec( val description: String, val algorithm: String, - override val algorithmParameterSpec: AlgorithmParameterSpec -) : CryptoSpec + val encryptionPadding : String, + override val algorithmParameterSpec: AlgorithmParameterSpec, + ) : CryptoSpec { + override fun toString(): String { + return "KeyGenSpec(description='$description', algorithm='$algorithm', encryptionPadding='$encryptionPadding')" + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt index a149e7b1f9..361dede2d9 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt @@ -23,6 +23,7 @@ package com.microsoft.identity.common.crypto import com.microsoft.identity.common.java.exception.ClientException +import java.security.KeyPair import javax.crypto.SecretKey /** @@ -31,14 +32,6 @@ import javax.crypto.SecretKey * Implementations can handle different algorithms and API versions. */ interface IKekManager { - /** - * Gets the appropriate cipher transformation to use with the generated keys. - * The transformation string specifies the algorithm, mode, and padding to be - * used for encryption/decryption operations. - * - * @return The cipher transformation string in the format "algorithm/mode/padding" - */ - val cipherTransformation: String /** * Wraps (encrypts) a secret key using the Key Encryption Key managed by this interface. @@ -64,16 +57,6 @@ interface IKekManager { * or insufficient permissions */ @Throws(ClientException::class) - fun unwrapKey(wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey + fun unwrapKey(keyPair: KeyPair, wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey - /** - * Checks if the Key Encryption Key exists and is accessible. - * This can be used to verify if a KEK is available before attempting operations. - * - * @return true if the KEK exists and is accessible, false otherwise - * @throws ClientException If checking for key existence fails due to - * security framework errors or insufficient permissions - */ - @Throws(ClientException::class) - fun kekExists(): Boolean } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt index 036e8ec681..d4c40648f8 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt @@ -33,6 +33,7 @@ import com.microsoft.identity.common.java.util.CachedData import com.microsoft.identity.common.java.util.FileUtil import com.microsoft.identity.common.logging.Logger import java.io.File +import java.security.KeyPair import javax.crypto.SecretKey /** @@ -112,11 +113,14 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( fun readSecretKeyFromStorage(): SecretKey? { val methodTag = "$TAG:readSecretKeyFromStorage" try { - if (!mKekManager.kekExists()) { + // Check if the KeyPair exists in the AndroidKeyStore. + val keyPair = AndroidKeyStoreUtil.readKey(alias) ?: run { Logger.info(methodTag, "key does not exist in keystore") deleteSecretKeyFromStorage() return null } + + // Check if the key file exists. val wrappedSecretKey = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) ?: run { Logger.warn(methodTag, "Key file is empty") // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache @@ -126,14 +130,8 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( keyCache.clear() return null } - val keyAlgorithm = FileUtil.readStringFromFile(keyAlgorithmFile) ?: run { - Logger.warn( - methodTag, "Key algorithm file is empty, " + - "using SecretKeyGenerator to get the key algorithm" - ) - AES256SecretKeyGenerator.AES_ALGORITHM - } - val key = mKekManager.unwrapKey(wrappedSecretKey, keyAlgorithm) + + val key = mKekManager.unwrapKey(keyPair, wrappedSecretKey, "AES") Logger.info( methodTag, "Key is loaded with thumbprint: " + KeyUtil.getKeyThumbPrint(key) @@ -206,7 +204,7 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( ) override val cipherTransformation: String - get() = mKekManager.cipherTransformation + get() = "AES/CBC/PKCS5Padding" override val keyTypeIdentifier: String get() = WRAPPED_KEY_KEY_IDENTIFIER diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index 517a46152d..954877e03b 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -23,6 +23,7 @@ package com.microsoft.identity.common.internal.util; import android.os.Build; +import android.security.keystore.KeyInfo; import android.util.Log; import com.microsoft.identity.common.java.exception.ClientException; @@ -36,6 +37,7 @@ import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.Key; +import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; @@ -47,6 +49,10 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.InvalidKeySpecException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Locale; @@ -482,4 +488,51 @@ public static synchronized SecretKey unwrap(@NonNull final byte[] wrappedKeyBlob throw clientException; } + public static synchronized @Nullable KeyInfo getKeyInfo(@NonNull final String alias) { + final String methodTag = TAG + ":getKeyInfo"; + try { + final KeyStore keyStore = getKeyStore(); + keyStore.load(null); + + final PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, null); + final KeyFactory factory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEY_STORE_TYPE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Logger.verbose(methodTag, "Getting KeyInfo for alias"); + return factory.getKeySpec(privateKey, KeyInfo.class); + } + } catch (final Exception e) { + Logger.warn(methodTag, "Failed to get KeyInfo for alias, swallowing exception"); + } + return null; + } + + /** + * Returns a list of encryption paddings supported by the key pair. + * + * @param keyPair The key pair for which to get the encryption paddings. + * @return A list of encryption paddings supported by the key pair. + */ + public static synchronized List getEncryptionPaddings(@NonNull final KeyPair keyPair) { + final String methodTag = TAG + ":getEncryptionPaddings"; + try { + final PrivateKey privateKey = keyPair.getPrivate(); + final KeyFactory keyFactory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEY_STORE_TYPE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + final KeyInfo keyInfo = keyFactory.getKeySpec(privateKey, KeyInfo.class); + final List encryptionPaddings = new ArrayList<>(); + // keyInfo.getEncryptionPaddings() returns a list of encryption paddings supported by the key. + // We remove the "Padding" suffix from each padding name to match the expected format. + for (final String padding : keyInfo.getEncryptionPaddings()) { + encryptionPaddings.add(padding.replace("Padding", "")); + } + return encryptionPaddings; + } else { + Logger.warn(methodTag, "getKeyInfo is not supported on this Android version"); + } + } catch (final Exception e) { + Logger.warn(methodTag, "Failed to get KeyInfo for alias, swallowing exception"); + } + return Collections.emptyList(); + } + } From 404e23ff3c5565651c979b112360aac3c7921510 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 10 Jul 2025 15:09:08 -0700 Subject: [PATCH 15/51] feat: Implement AndroidWrappedKeyProviderFactory and OAEPAndroidWrappedKeyProvider - Added AndroidWrappedKeyProviderFactory to create wrapped key loaders based on feature flags. - Introduced OAEPAndroidWrappedKeyProvider for handling key wrapping and unwrapping using OAEP padding. - Updated CryptoParameterSpecFactory to support new key generation specifications and improved documentation. - Refactored CryptoSpecs to enhance clarity and maintainability. - Removed deprecated methods from AndroidKeyStoreUtil to streamline key management. - Enhanced unit tests for CryptoParameterSpecFactory to cover new functionality and edge cases. - Cleaned up FileUtil by removing unused methods related to string file operations. --- .../crypto/AndroidWrappedKeyProviderTest.java | 16 +- ...kt => AndroidWrappedKeyProviderFactory.kt} | 7 +- .../crypto/CryptoParameterSpecFactory.kt | 79 +++-- .../identity/common/crypto/CryptoSpecs.kt | 46 ++- ...er.kt => OAEPAndroidWrappedKeyProvider.kt} | 17 +- .../internal/util/AndroidKeyStoreUtil.java | 27 +- .../CryptoParameterSpecFactoryTest.java | 331 ++++-------------- .../identity/common/java/util/FileUtil.java | 46 --- 8 files changed, 161 insertions(+), 408 deletions(-) rename common/src/main/java/com/microsoft/identity/common/crypto/{AndroidWrappedKeyLoaderFactory.kt => AndroidWrappedKeyProviderFactory.kt} (94%) rename common/src/main/java/com/microsoft/identity/common/crypto/{NewAndroidWrappedKeyProvider.kt => OAEPAndroidWrappedKeyProvider.kt} (93%) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index 5a934e3880..f7a5f12291 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -133,7 +133,7 @@ public void testGenerateKey() throws ClientException { @Test public void testReadKeyDirectly() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final OAEPAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); final SecretKey secretKey = keyProvider.getKey(); final SecretKey storedSecretKey = keyProvider.readSecretKeyFromStorage(); @@ -255,7 +255,7 @@ public void testPerf_NoCachedKey() throws ClientException { */ @Test public void testLoadDeletedKeyStoreKey() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final OAEPAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); @@ -266,7 +266,7 @@ public void testLoadDeletedKeyStoreKey() throws ClientException { @Test public void testLoadDeletedKeyFile() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final OAEPAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); FileUtil.deleteFile(getKeyFile()); @@ -315,8 +315,8 @@ public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProv } - private NewAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + private OAEPAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws ClientException { + final OAEPAndroidWrappedKeyProvider keyProvider = new OAEPAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey key = keyProvider.getKey(); Assert.assertNotNull(key); Assert.assertNotNull(keyProvider.getKeyCache().getData()); @@ -324,14 +324,14 @@ private NewAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws Client } /** - * Helper method to generate a random key using NewAndroidWrappedKeyProvider. + * Helper method to generate a random key using OAEPAndroidWrappedKeyProvider. * This method is used to substitute the call to keyLoader.generateRandomKey() in tests. * - * @param keyLoader The NewAndroidWrappedKeyProvider instance to use + * @param keyLoader The OAEPAndroidWrappedKeyProvider instance to use * @return The generated SecretKey * @throws ClientException if key generation fails */ - private SecretKey generateRandomKey(NewAndroidWrappedKeyProvider keyLoader) throws ClientException { + private SecretKey generateRandomKey(OAEPAndroidWrappedKeyProvider keyLoader) throws ClientException { // Get the key will generate a new one if it doesn't exist SecretKey key = keyLoader.getKey(); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt similarity index 94% rename from common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt index d5f07d7fd4..b5264c505e 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyLoaderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt @@ -34,8 +34,7 @@ import com.microsoft.identity.common.java.flighting.CommonFlightsManager * specific loader implementation should be used, allowing for runtime switching between * different implementations without affecting client code. */ -object AndroidWrappedKeyLoaderFactory { - const val WRAPPED_KEY_KEY_IDENTIFIER: String = "A001" +object AndroidWrappedKeyProviderFactory { var skipKeyInvalidationCheck: Boolean = false /** * Creates an appropriate wrapped key loader instance based on current feature flags. @@ -49,7 +48,7 @@ object AndroidWrappedKeyLoaderFactory { * @param context The Android application context needed for file and security operations * @return An implementation of [ISecretKeyProvider] that can load the specified wrapped key */ - fun createWrappedKeyLoader( + fun createWrappedKeyProvider( keyIdentifier: String, fileName: String, context: android.content.Context @@ -60,7 +59,7 @@ object AndroidWrappedKeyLoaderFactory { .isFlightEnabled(CommonFlight.ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER) return if (useNewAndroidWrappedKeyLoader) { - NewAndroidWrappedKeyProvider( + OAEPAndroidWrappedKeyProvider( keyIdentifier, fileName, context diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index 0803e35fe7..b28fe43572 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -42,12 +42,12 @@ import javax.crypto.spec.PSource import javax.security.auth.x500.X500Principal /** - * Factory class to create various cryptographic parameter specifications - * for key generation and cipher operations. + * A factory for creating cryptographic parameter specifications for key generation and cipher operations. * - * This class encapsulates the logic to determine which key generation and cipher specs - * to use based on the Android API level and feature flags. It implements a fallback mechanism - * to ensure compatibility across different Android versions and device implementations. + * This class encapsulates the logic for determining the appropriate key generation and cipher + * specifications based on the Android API level and configurable feature flags. It provides a + * fallback mechanism to ensure compatibility across different Android versions and device-specific + * hardware implementations. * * Key features: * - Creates appropriate key generation specifications based on Android API level @@ -76,19 +76,24 @@ class CryptoParameterSpecFactory( private companion object { private val TAG = CryptoParameterSpecFactory::class.java.simpleName + // Algorithm used for RSA key generation and encryption private const val RSA_ALGORITHM = "RSA" + // Default key size for RSA keys private const val KEY_SIZE: Int = 2048 + // Descriptive identifiers for different key generation specifications private const val MODERN_SPEC_WITH_PURPOSE_WRAP_KEY = "modern_spec_with_wrap_key" private const val MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY = "modern_spec_without_wrap_key" private const val LEGACY_SPEC = "legacy_key_gen_spec" + // Padding schemes and modes used in cipher operations private const val PKCS1_PADDING = "PKCS1Padding" - private const val OAEP_PADDING_WITH_256MGF1 = "OAEPWithSHA-256AndMGF1Padding" + private const val OAEP_PADDING_WITH_256MGF1 = "OAEPwithSHA-256andMGF1Padding" private const val MODE_ECB = "ECB" private const val MODE_NONE = "NONE" + // OAEP parameter specification for RSA encryption private val OAEP_SPECS = OAEPParameterSpec( "SHA-256", // main digest @@ -123,28 +128,32 @@ class CryptoParameterSpecFactory( ) // Key generation parameter specifications - @RequiresApi(Build.VERSION_CODES.P) - private val keyGenParamSpecWithPurposeWrapKey = KeyGenSpec( - algorithmParameterSpec = createKeyGenParameterSpec( - KeyProperties.PURPOSE_ENCRYPT or - KeyProperties.PURPOSE_DECRYPT or - KeyProperties.PURPOSE_WRAP_KEY - ), - description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY, - encryptionPadding = getEncryptionPaddings(), - algorithm = RSA_ALGORITHM - ) + @delegate:RequiresApi(Build.VERSION_CODES.P) + private val keyGenParamSpecWithPurposeWrapKey by lazy { + KeyGenSpec( + algorithmParameterSpec = createKeyGenParameterSpec( + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT or + KeyProperties.PURPOSE_WRAP_KEY + ), + description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY, + encryptionPadding = getEncryptionPaddings(), + algorithm = RSA_ALGORITHM + ) + } - @RequiresApi(Build.VERSION_CODES.M) - private val keyGenParamSpecWithoutPurposeWrapKey = KeyGenSpec( - algorithmParameterSpec = createKeyGenParameterSpec( - KeyProperties.PURPOSE_ENCRYPT or - KeyProperties.PURPOSE_DECRYPT - ), - description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY, - encryptionPadding = getEncryptionPaddings(), - algorithm = RSA_ALGORITHM - ) + @delegate:RequiresApi(Build.VERSION_CODES.M) + private val keyGenParamSpecWithoutPurposeWrapKey by lazy { + KeyGenSpec( + algorithmParameterSpec = createKeyGenParameterSpec( + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT + ), + description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY, + encryptionPadding = getEncryptionPaddings(), + algorithm = RSA_ALGORITHM + ) + } private val keyGenParamSpecLegacy = KeyGenSpec( algorithmParameterSpec = getLegacyKeyGenParamSpec(), @@ -198,8 +207,8 @@ class CryptoParameterSpecFactory( return KeyGenParameterSpec.Builder(keyAlias, purposes) .setKeySize(KEY_SIZE) .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(getEncryptionPaddings()) - .build() + .setEncryptionPaddings(getEncryptionPaddings()) + .build() } init { @@ -234,14 +243,8 @@ class CryptoParameterSpecFactory( */ fun getPrioritizedCipherParameterSpecs(): List { val methodTag = "$TAG:getPrioritizedCipherParameterSpecs" - val specs = mutableListOf() - // Add OAEP padding spec first (if enabled) as it provides stronger security - if (supportsEncryptionPaddingRsaOaep) { - specs.add(oaepCipherSpec) - } - // Always include legacy PKCS1 padding as a fallback for compatibility - specs.add(pkcs1CipherSpec) - Logger.info(methodTag, "Options: ${specs.joinToString { it.transformation }}") + val specs = listOf(oaepCipherSpec, pkcs1CipherSpec) + Logger.info(methodTag, "Ciphers: $specs") return specs } @@ -281,7 +284,7 @@ class CryptoParameterSpecFactory( specs.add(keyGenParamSpecLegacy) Logger.info(methodTag, "Options: ${specs.joinToString { it.description }}") - return specs + return specs.toList() } /** diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt index 7d5204ba73..59af269393 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt @@ -25,15 +25,30 @@ package com.microsoft.identity.common.crypto import java.security.spec.AlgorithmParameterSpec -interface CryptoSpec{ +/** + * A base interface for cryptographic specifications. + * + * This interface provides a common structure for different types of cryptographic parameter + * specifications used throughout the application. It ensures that any specification class + * includes an [AlgorithmParameterSpec], which is a standard Java Security class for specifying + * algorithm parameters. + */ +interface CryptoSpec { val algorithmParameterSpec: AlgorithmParameterSpec? } /** - * Data class to hold cipher parameter specifications. + * Data class to hold cipher parameter specifications for encryption and decryption operations. + * + * This class defines the components needed to create a [javax.crypto.Cipher] instance, + * including the algorithm, block mode, and padding scheme. It also constructs the full + * transformation string required by the Cipher API. * - * @property algorithmParameterSpec The algorithm parameter specification (can be null). - * @property transformation The transformation string (e.g., "RSA/ECB/PKCS1Padding"). + * @property algorithmParameterSpec The algorithm parameter specification (e.g., [javax.crypto.spec.OAEPParameterSpec]), + * which can be null if not required by the transformation. + * @property algorithm The name of the cryptographic algorithm (e.g., "RSA"). + * @property mode The block cipher mode of operation (e.g., "ECB", "CBC"). + * @property padding The padding scheme used for the cipher (e.g., "PKCS1Padding", "OAEPwithSHA-256andMGF1Padding"). */ data class CipherSpec( override val algorithmParameterSpec: AlgorithmParameterSpec?, @@ -41,6 +56,10 @@ data class CipherSpec( val mode: String, val padding: String, ) : CryptoSpec { + /** + * The full transformation string (e.g., "RSA/ECB/PKCS1Padding") used to initialize a + * [javax.crypto.Cipher] instance. + */ val transformation = "$algorithm/$mode/$padding" override fun toString(): String { @@ -49,19 +68,26 @@ data class CipherSpec( } /** - * Data class to hold key generation parameter specifications. + * Data class to hold parameter specifications for cryptographic key generation. * - * @property algorithmParameterSpec The key generation parameter specification. - * @property description A descriptive string for the specification. + * This class encapsulates all the necessary information to generate a new cryptographic key pair, + * including a description for logging, the algorithm, the padding scheme to be associated with the key, + * and the detailed algorithm parameter specification. + * + * @property description A descriptive string for the specification, useful for logging and debugging. + * @property algorithm The key generation algorithm, typically "RSA". + * @property encryptionPadding The encryption padding scheme that the generated key will support + * (e.g., [android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1]). + * @property algorithmParameterSpec The detailed key generation parameter specification, such as + * [android.security.keystore.KeyGenParameterSpec] or [android.security.KeyPairGeneratorSpec]. */ data class KeyGenSpec( val description: String, val algorithm: String, - val encryptionPadding : String, + val encryptionPadding: String, override val algorithmParameterSpec: AlgorithmParameterSpec, - ) : CryptoSpec { +) : CryptoSpec { override fun toString(): String { return "KeyGenSpec(description='$description', algorithm='$algorithm', encryptionPadding='$encryptionPadding')" } } - diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/OAEPAndroidWrappedKeyProvider.kt similarity index 93% rename from common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/OAEPAndroidWrappedKeyProvider.kt index d4c40648f8..ff749ee703 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/OAEPAndroidWrappedKeyProvider.kt @@ -23,7 +23,6 @@ package com.microsoft.identity.common.crypto import android.content.Context -import com.microsoft.identity.common.crypto.AndroidWrappedKeyLoaderFactory.WRAPPED_KEY_KEY_IDENTIFIER import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider @@ -33,7 +32,6 @@ import com.microsoft.identity.common.java.util.CachedData import com.microsoft.identity.common.java.util.FileUtil import com.microsoft.identity.common.logging.Logger import java.io.File -import java.security.KeyPair import javax.crypto.SecretKey /** @@ -43,7 +41,7 @@ import javax.crypto.SecretKey * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key * before it get saved to the file. */ -class NewAndroidWrappedKeyProvider @JvmOverloads constructor( +class OAEPAndroidWrappedKeyProvider @JvmOverloads constructor( override val alias: String, private val mFilePath: String, private val mContext: Context, @@ -54,7 +52,7 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( // Exposed for testing only. val keyCache: CachedData = object : CachedData() { override fun getData(): SecretKey? { - if (AndroidWrappedKeyLoaderFactory.skipKeyInvalidationCheck) { + if (AndroidWrappedKeyProviderFactory.skipKeyInvalidationCheck) { return super.getData() } if ((!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists())) { @@ -96,7 +94,7 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( methodTag, "New key is generated with thumbprint: " + KeyUtil.getKeyThumbPrint(newKey) ) - saveSecretKeyToStorage(newKey, newKey.algorithm) + saveSecretKeyToStorage(newKey) keyCache.data = newKey return newKey } @@ -154,9 +152,7 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( */ @Throws(ClientException::class) private fun saveSecretKeyToStorage( - unencryptedKey: SecretKey, - keyAlgorithm: String - ) { + unencryptedKey: SecretKey) { /* * !!WARNING!! * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring @@ -176,7 +172,6 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( */ val keyWrapped = mKekManager.wrapKey(unencryptedKey) FileUtil.writeDataToFile(keyWrapped, keyFile) - FileUtil.writeStringToFile(keyAlgorithm, keyAlgorithmFile) } /** @@ -207,10 +202,10 @@ class NewAndroidWrappedKeyProvider @JvmOverloads constructor( get() = "AES/CBC/PKCS5Padding" override val keyTypeIdentifier: String - get() = WRAPPED_KEY_KEY_IDENTIFIER + get() = "WRAPPED_KEY_KEY_IDENTIFIER" companion object { - private val TAG = NewAndroidWrappedKeyProvider::class.java.simpleName + private val TAG = OAEPAndroidWrappedKeyProvider::class.java.simpleName // Exposed for testing only. const val KEY_FILE_SIZE: Int = 1024 diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index 954877e03b..8a0f7b7500 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -24,7 +24,6 @@ import android.os.Build; import android.security.keystore.KeyInfo; -import android.util.Log; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.logging.Logger; @@ -49,9 +48,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.spec.AlgorithmParameterSpec; -import java.security.spec.InvalidKeySpecException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -369,7 +366,6 @@ public static synchronized byte[] wrap(@NonNull final SecretKey key, final Throwable exception; final String errCode; try { - Log.i(methodTag, "Wrap a key with algorithm: " + wrapAlgorithm); final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); if (algorithmParameterSpec != null) { wrapCipher.init(Cipher.WRAP_MODE, keyToWrap.getPublic(), algorithmParameterSpec); @@ -419,7 +415,7 @@ public static synchronized byte[] wrap(@NonNull final SecretKey key, * @param wrapAlgorithm the algorithm used to wrap the key. * @return the unwrapped key. */ - public static synchronized SecretKey unwrap(@NonNull final byte[] wrappedKeyBlob, + public static synchronized SecretKey unwrap(final byte[] wrappedKeyBlob, @NonNull final String wrappedKeyAlgorithm, @NonNull final KeyPair keyPairForUnwrapping, @NonNull final String wrapAlgorithm, @@ -428,10 +424,8 @@ public static synchronized SecretKey unwrap(@NonNull final byte[] wrappedKeyBlob final Throwable exception; final String errCode; try { - Log.i(methodTag, "unwrap a key with algorithm: " + wrapAlgorithm); //TODO: Once the new KeyProvider is fully implemented, we can remove this suppression. final Cipher wrapCipher = Cipher.getInstance(wrapAlgorithm); // CodeQL [SM05136] Used on AndroidWrappedKeyLoader, will be removed once the new KeyProvider is fully implemented. - if (algorithmParameterSpec != null) { wrapCipher.init(Cipher.UNWRAP_MODE, keyPairForUnwrapping.getPrivate(), algorithmParameterSpec); } else { @@ -488,24 +482,6 @@ public static synchronized SecretKey unwrap(@NonNull final byte[] wrappedKeyBlob throw clientException; } - public static synchronized @Nullable KeyInfo getKeyInfo(@NonNull final String alias) { - final String methodTag = TAG + ":getKeyInfo"; - try { - final KeyStore keyStore = getKeyStore(); - keyStore.load(null); - - final PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, null); - final KeyFactory factory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEY_STORE_TYPE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Logger.verbose(methodTag, "Getting KeyInfo for alias"); - return factory.getKeySpec(privateKey, KeyInfo.class); - } - } catch (final Exception e) { - Logger.warn(methodTag, "Failed to get KeyInfo for alias, swallowing exception"); - } - return null; - } - /** * Returns a list of encryption paddings supported by the key pair. * @@ -534,5 +510,4 @@ public static synchronized List getEncryptionPaddings(@NonNull final Key } return Collections.emptyList(); } - } diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java index ec55dd95ce..f2be7a0b7e 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java +++ b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java @@ -23,29 +23,25 @@ package com.microsoft.identity.common.crypto; import static org.junit.Assert.*; +import static org.mockito.Mockito.when; import android.content.Context; import android.os.Build; +import android.security.keystore.KeyProperties; import com.microsoft.identity.common.java.flighting.CommonFlight; -import com.microsoft.identity.common.java.flighting.IFlightConfig; import com.microsoft.identity.common.java.flighting.IFlightsProvider; -import org.json.JSONObject; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import java.security.spec.MGF1ParameterSpec; import java.util.List; -import javax.crypto.spec.OAEPParameterSpec; - -import lombok.NonNull; - /** * Unit tests for {@link CryptoParameterSpecFactory} */ @@ -62,73 +58,22 @@ public class CryptoParameterSpecFactoryTest { private CryptoParameterSpecFactory cryptoParameterSpecFactory; - private Boolean oeapEnabled = true; // Simulate OAEP enabled by default - private Boolean newKeyGenSpecWithWrapKeyEnabled = true; // Simulate new key gen spec with wrap key enabled - private Boolean newKeyGenSpecWithoutWrapKeyEnabled = true; // Simulate new key gen spec without wrap key enabled @Before public void setUp() { - // Setup mock flights provider - mockFlightsProvider = new IFlightsProvider() { - - @Override - public boolean isFlightEnabled(@NonNull IFlightConfig flightConfig) { - if (flightConfig.getKey().equals(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING.getKey())) { - // Simulate OAEP enabled for testing - return oeapEnabled; // Change to false to test PKCS1 path - } else if (flightConfig.getKey().equals(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY.getKey())) { - return newKeyGenSpecWithWrapKeyEnabled; // Simulate new key gen spec enabled - } else if (flightConfig.getKey().equals(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY.getKey())) { - return newKeyGenSpecWithoutWrapKeyEnabled; // Simulate modern spec without wrap key enabled - } - return false; - } - - @Override - public boolean getBooleanValue(@NonNull IFlightConfig flightConfig) { - return false; - } - - @Override - public int getIntValue(@NonNull IFlightConfig flightConfig) { - return 0; - } - - @Override - public double getDoubleValue(@NonNull IFlightConfig flightConfig) { - return 0; - } - - @Override - public String getStringValue(@NonNull IFlightConfig flightConfig) { - return ""; - } - - @Override - public JSONObject getJsonValue(@NonNull IFlightConfig flightConfig) { - return null; - } - }; - - - mockContext = org.mockito.Mockito.mock(Context.class); - // Create the instance to test - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + mockFlightsProvider = Mockito.mock(IFlightsProvider.class); + mockContext = Mockito.mock(Context.class); } - @Test - public void testGetPrioritizedCipherParameterSpec_WithOAEPEnabled() { - // When OAEP is enabled - //mockFlightsProvider.addFlight(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING.getKey(), "true"); - + public void testGetPrioritizedCipherParameterSpec() { // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS,mockFlightsProvider); + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); + final List specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); // Verify we have 2 specs (OAEP and PKCS1) in that order assertEquals(2, specs.size()); @@ -136,68 +81,87 @@ public void testGetPrioritizedCipherParameterSpec_WithOAEPEnabled() { assertEquals("RSA/ECB/PKCS1Padding", specs.get(1).getTransformation()); } - @Test - public void testGetPrioritizedCipherParameterSpec_WithOAEPDisabled() { - // When OAEP is disabled - oeapEnabled = false; - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); - - // Verify we have only 1 spec (PKCS1) - assertEquals(1, specs.size()); - assertEquals("RSA/ECB/PKCS1Padding", specs.get(0).getTransformation()); - } - @Test public void testGetPrioritizedKeyGenParameterSpecs_AllFlagsEnabled() { // When all flags are enabled + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true); + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true); + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(true); // Re-create the factory with the updated flags cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); // Verify we have 3 specs in the right order assertEquals(3, specs.size()); assertEquals("modern_spec_with_wrap_key", specs.get(0).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, specs.get(0).getEncryptionPadding()); + assertEquals("modern_spec_without_wrap_key", specs.get(1).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, specs.get(1).getEncryptionPadding()); + assertEquals("legacy_key_gen_spec", specs.get(2).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(2).getEncryptionPadding()); + } @Test public void testGetPrioritizedKeyGenParameterSpecs_WithoutPurposeWrapKeyOnly() { // When only WITHOUT_PURPOSE_WRAP_KEY flag is enabled - newKeyGenSpecWithWrapKeyEnabled = false; // Simulate wrap key disabled - newKeyGenSpecWithoutWrapKeyEnabled = true; // Simulate modern spec without wrap key enabled + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true); // Re-create the factory with the updated flags cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); // Verify we have 2 specs in the right order assertEquals(2, specs.size()); assertEquals("modern_spec_without_wrap_key", specs.get(0).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(0).getEncryptionPadding()); + + assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(1).getEncryptionPadding()); + } + + @Test + public void testGetPrioritizedKeyGenParameterSpecs_WithPurposeWrapKeyOnly() { + // When only WITHOUT_PURPOSE_WRAP_KEY flag is enabled + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true); + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); + + // Get the prioritized specs + final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + + // Verify we have 2 specs in the right order + assertEquals(2, specs.size()); + assertEquals("modern_spec_with_wrap_key", specs.get(0).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(0).getEncryptionPadding()); + assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(1).getEncryptionPadding()); } @Test public void testGetPrioritizedKeyGenParameterSpecs_NoFlagsEnabled() { // When no flags are enabled - newKeyGenSpecWithWrapKeyEnabled = false; // Simulate wrap key disabled - newKeyGenSpecWithoutWrapKeyEnabled = false; // Simulate modern spec without wrap key disabled // Re-create the factory with the updated flags cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); + final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); // Verify we have only the legacy spec assertEquals(1, specs.size()); assertEquals("legacy_key_gen_spec", specs.get(0).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(0).getEncryptionPadding()); } @@ -206,6 +170,12 @@ public void testGetPrioritizedKeyGenParameterSpecs_NoFlagsEnabled() { public void testGetPrioritizedKeyGenParameterSpecs_API23_WithFlags() { // Test on API 23 (M) with flags enabled // Should include modern spec without wrap key but not the one with wrap key (requires API 28) + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true); + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true); + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(true); // Re-create the factory with the updated flags cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); @@ -215,13 +185,23 @@ public void testGetPrioritizedKeyGenParameterSpecs_API23_WithFlags() { // Verify we have 2 specs in the right order (no PURPOSE_WRAP_KEY since it needs API 28) assertEquals(2, specs.size()); assertEquals("modern_spec_without_wrap_key", specs.get(0).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, specs.get(0).getEncryptionPadding()); + assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); + assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(1).getEncryptionPadding()); } @Test - @Config(sdk = Build.VERSION_CODES.LOLLIPOP) // API 21, before M + @Config(sdk = Build.VERSION_CODES.LOLLIPOP_MR1) // API 22, before M public void testGetPrioritizedKeyGenParameterSpecs_LegacyAPI() { // Test on pre-M API where only legacy spec should be available + // Should include modern spec without wrap key but not the one with wrap key (requires API 28) + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true); + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true); + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(true); // Re-create the factory with the updated flags cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); @@ -232,183 +212,4 @@ public void testGetPrioritizedKeyGenParameterSpecs_LegacyAPI() { assertEquals(1, specs.size()); assertEquals("legacy_key_gen_spec", specs.get(0).getDescription()); } - - @Test - public void testGetAlgorithmParameterSpec_WithOAEPEnabled() { - // When OAEP is enabled - oeapEnabled = true; - - // Re-create the factory - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Call the method via reflection since it's private - try { - java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( - "getAlgorithmParameterSpec", int.class); - method.setAccessible(true); - - Object spec = method.invoke(cryptoParameterSpecFactory, android.security.keystore.KeyProperties.PURPOSE_ENCRYPT); - - assertNotNull("Algorithm parameter spec should not be null", spec); - assertTrue("Should return a KeyGenParameterSpec", - spec instanceof android.security.keystore.KeyGenParameterSpec); - - // For OAEP, it should include DIGEST_SHA256 and DIGEST_SHA1 - android.security.keystore.KeyGenParameterSpec keySpec = - (android.security.keystore.KeyGenParameterSpec) spec; - - // Unfortunately we can't directly check the digests, but we can verify - // the padding is set correctly if OAEP is enabled - java.lang.reflect.Field builderField = keySpec.getClass().getDeclaredField("mEncryptionPaddings"); - builderField.setAccessible(true); - String[] paddings = (String[]) builderField.get(keySpec); - - assertEquals(1, paddings.length); - assertEquals(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, paddings[0]); - - } catch (Exception e) { - fail("Failed to test getAlgorithmParameterSpec: " + e.getMessage()); - } - } - - @Test - public void testGetAlgorithmParameterSpec_WithOAEPDisabled() { - // When OAEP is disabled - oeapEnabled = false; - - // Re-create the factory - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Call the method via reflection since it's private - try { - java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( - "getAlgorithmParameterSpec", int.class); - method.setAccessible(true); - - Object spec = method.invoke(cryptoParameterSpecFactory, android.security.keystore.KeyProperties.PURPOSE_ENCRYPT); - - assertNotNull("Algorithm parameter spec should not be null", spec); - assertTrue("Should return a KeyGenParameterSpec", - spec instanceof android.security.keystore.KeyGenParameterSpec); - - // For PKCS1, it should include DIGEST_SHA256 and DIGEST_SHA512 - android.security.keystore.KeyGenParameterSpec keySpec = - (android.security.keystore.KeyGenParameterSpec) spec; - - // Unfortunately we can't directly check the digests, but we can verify - // the padding is set correctly if OAEP is disabled - java.lang.reflect.Field builderField = keySpec.getClass().getDeclaredField("mEncryptionPaddings"); - builderField.setAccessible(true); - String[] paddings = (String[]) builderField.get(keySpec); - - assertEquals(1, paddings.length); - assertEquals(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, paddings[0]); - - } catch (Exception e) { - fail("Failed to test getAlgorithmParameterSpec: " + e.getMessage()); - } - } - - @Test - public void testGetLegacyKeyGenParamSpec() { - // Test the legacy key generation parameter spec - try { - java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( - "getLegacyKeyGenParamSpec"); - method.setAccessible(true); - - Object spec = method.invoke(cryptoParameterSpecFactory); - - assertNotNull("Legacy key gen parameter spec should not be null", spec); - assertTrue("Should return a KeyPairGeneratorSpec", - spec instanceof android.security.KeyPairGeneratorSpec); - - android.security.KeyPairGeneratorSpec legacySpec = - (android.security.KeyPairGeneratorSpec) spec; - - // Check key alias is correctly set - java.lang.reflect.Field aliasField = legacySpec.getClass().getDeclaredField("mKeystoreAlias"); - aliasField.setAccessible(true); - String alias = (String) aliasField.get(legacySpec); - - assertEquals(TEST_KEY_ALIAS, alias); - - } catch (Exception e) { - fail("Failed to test getLegacyKeyGenParamSpec: " + e.getMessage()); - } - } - - @Test - public void testKeySize() { - // Test that the default key size is set correctly (2048) - try { - java.lang.reflect.Method method = CryptoParameterSpecFactory.class.getDeclaredMethod( - "getAlgorithmParameterSpec", int.class); - method.setAccessible(true); - - Object spec = method.invoke(cryptoParameterSpecFactory, android.security.keystore.KeyProperties.PURPOSE_ENCRYPT); - - assertNotNull("Algorithm parameter spec should not be null", spec); - assertTrue("Should return a KeyGenParameterSpec", - spec instanceof android.security.keystore.KeyGenParameterSpec); - - android.security.keystore.KeyGenParameterSpec keySpec = - (android.security.keystore.KeyGenParameterSpec) spec; - - // Check key size field - java.lang.reflect.Field keySizeField = keySpec.getClass().getDeclaredField("mKeySize"); - keySizeField.setAccessible(true); - int keySize = (int) keySizeField.get(keySpec); - - assertEquals(2048, keySize); - - } catch (Exception e) { - fail("Failed to test key size: " + e.getMessage()); - } - } - - @Test - public void testOAEPParameterSpec() { - // Specifically test the OAEP spec configuration - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - try { - // Access the oaepSpec field by reflection - java.lang.reflect.Field oaepSpecField = CryptoParameterSpecFactory.class.getDeclaredField("oaepSpec"); - oaepSpecField.setAccessible(true); - - Object oaepSpec = oaepSpecField.get(null); // it's a static field - - assertNotNull("OAEP spec should not be null", oaepSpec); - assertTrue("Should be an OAEPParameterSpec", oaepSpec instanceof OAEPParameterSpec); - - OAEPParameterSpec spec = (OAEPParameterSpec) oaepSpec; - - assertEquals("SHA-256", spec.getDigestAlgorithm()); - assertEquals("MGF1", spec.getMGFAlgorithm()); - assertTrue(spec.getMGFParameters() instanceof MGF1ParameterSpec); - assertEquals(MGF1ParameterSpec.SHA1, spec.getMGFParameters()); - - } catch (Exception e) { - fail("Failed to test OAEP parameter spec: " + e.getMessage()); - } - } - - @Test - public void testWithPurposeWrapKeyOnly() { - // Test with only PURPOSE_WRAP_KEY enabled - newKeyGenSpecWithWrapKeyEnabled = true; - newKeyGenSpecWithoutWrapKeyEnabled = false; - - // Re-create the factory - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - - // Verify we have 2 specs in the right order - assertEquals(2, specs.size()); - assertEquals("modern_spec_with_wrap_key", specs.get(0).getDescription()); - assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); - } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java index 9ab5b8ddee..c33e292848 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java +++ b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java @@ -22,7 +22,6 @@ // THE SOFTWARE. package com.microsoft.identity.common.java.util; -import com.microsoft.identity.common.java.controllers.ExceptionAdapter; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.logging.Logger; @@ -33,7 +32,6 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.nio.charset.StandardCharsets; import edu.umd.cs.findbugs.annotations.Nullable; import lombok.NonNull; @@ -146,48 +144,4 @@ public static void deleteFile(@NonNull final File file) { } } - /** - * Read a string from a file. - * - * @param file the file to read from. - * @return the content of the file as a String, or null if an error occurs. - */ - public static String readStringFromFile(File file) { - final String methodTag = TAG + ":readStringFromFile"; - - try (FileInputStream fis = new FileInputStream(file); - final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - - byte[] buffer = new byte[1024]; - int length; - while ((length = fis.read(buffer)) != -1) { - baos.write(buffer, 0, length); - } - - return baos.toString("UTF-8"); - } catch (IOException e) { - Logger.error(methodTag, e.getMessage(), e); - return null; // or handle the exception as needed - } - } - - /** - * Write a string to a file. - * - * @param content the string content to write. - * @param file the file to write to. - * @throws ClientException if an error occurs during writing. - */ - public static void writeStringToFile(String content, File file) throws ClientException { - final String methodTag = TAG + ":writeStringToFile"; - - try (FileOutputStream fos = new FileOutputStream(file)) { - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - baos.write(content.getBytes(StandardCharsets.UTF_8)); - baos.writeTo(fos); - } catch (final IOException e) { - Logger.error(methodTag, e.getMessage(), e); - throw ExceptionAdapter.clientExceptionFromException(e); - } - } } From 8e62a7b4c160971fa1d48b6c91d450a3b1188d32 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 11 Jul 2025 12:26:07 -0700 Subject: [PATCH 16/51] phasex --- .../AndroidWrappedKeyProviderFactory.kt | 6 +- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 346 +++++++++ .../crypto/NewAndroidWrappedKeyProvider.java | 483 +++++++++++++ .../internal/util/AndroidKeyStoreUtil.java | 1 + .../util/AndroidKeyStoreUtilTest.java | 658 ++++++++++++++++++ .../common/java/flighting/CommonFlight.java | 4 +- 6 files changed, 1493 insertions(+), 5 deletions(-) create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java create mode 100644 common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt index b5264c505e..bc22b87a8b 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt @@ -53,12 +53,12 @@ object AndroidWrappedKeyProviderFactory { fileName: String, context: android.content.Context ): ISecretKeyProvider { - val useNewAndroidWrappedKeyLoader = + val enableOaepAndroidWrappedKeyLoader = CommonFlightsManager .getFlightsProvider() - .isFlightEnabled(CommonFlight.ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER) + .isFlightEnabled(CommonFlight.ENABLE_OAEP_ANDROID_WRAPPED_KEY_LOADER) - return if (useNewAndroidWrappedKeyLoader) { + return if (enableOaepAndroidWrappedKeyLoader) { OAEPAndroidWrappedKeyProvider( keyIdentifier, fileName, diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt new file mode 100644 index 0000000000..49821761e0 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import android.content.Context +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil +import com.microsoft.identity.common.java.controllers.ExceptionAdapter +import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator.generateRandomKey +import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider +import com.microsoft.identity.common.java.crypto.key.KeyUtil +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.opentelemetry.AttributeName +import com.microsoft.identity.common.java.opentelemetry.OTelUtility +import com.microsoft.identity.common.java.opentelemetry.SpanExtension +import com.microsoft.identity.common.java.opentelemetry.SpanName +import com.microsoft.identity.common.java.util.FileUtil +import com.microsoft.identity.common.logging.Logger +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.opentelemetry.api.trace.StatusCode +import java.io.File +import java.security.KeyPair +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import javax.crypto.SecretKey + +/** + * This class doesn't really use the KeyStore-generated key directly. + * + * + * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key + * before it get saved to the file. + */ +class KeyStoreBackedSecretKeyProvider ( + override val alias: String, + private val mFilePath: String, + private val mContext: Context +) : ISecretKeyProvider { + + @get:VisibleForTesting + val keyFromCache: SecretKey? + get() = sKeyCacheMap[mFilePath] + + @VisibleForTesting + fun clearKeyFromCache() { + sKeyCacheMap.remove(mFilePath) + } + + @VisibleForTesting + @Throws(ClientException::class) + fun deleteSecretKeyAndCleanup() { + AndroidKeyStoreUtil.deleteKey(alias) + FileUtil.deleteFile(keyFile) + sKeyCacheMap.remove(mFilePath) + } + + private val keyFile: File + get() = File( + mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), + mFilePath + ) + + + private val cryptoParameterSpecFactory = CryptoParameterSpecFactory(mContext, alias) + + override val keyTypeIdentifier = WRAPPED_KEY_KEY_IDENTIFIER + + override val cipherTransformation = AES_CIPHER_TRANSFORMATION + + @get:Throws(ClientException::class) + @get:Synchronized + override val key: SecretKey + get() { + val methodTag = "$TAG:getKey" + if (!sSkipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists())) { + clearKeyFromCache() + } + // If key is on cache, return it. + keyFromCache?.let { keyFromCache -> + Logger.info( + methodTag, "Key is loaded from cache with thumbprint: " + + KeyUtil.getKeyThumbPrint(keyFromCache) + ) + return keyFromCache + } + // If key is in storage, load it. + Logger.info(methodTag, "Key not in cache or cache is empty.") + readSecretKeyFromStorage()?.let { keyFromStorage -> + Logger.info( + methodTag, "Key is loaded from storage with thumbprint: " + + KeyUtil.getKeyThumbPrint(keyFromStorage) +", caching it." + ) + sKeyCacheMap[mFilePath] = keyFromStorage + return keyFromStorage + } + Logger.info(methodTag, "Key does not exist in storage.") + val newKey = generateAndStoreSecretKey() + sKeyCacheMap[mFilePath] = newKey + return newKey + } + + @Throws(ClientException::class) + private fun generateAndStoreSecretKey(): SecretKey { + /* + * !!WARNING!! + * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring + * the sharedUserId attribute in their Android Manifest file. If multiple apps reference + * the same value for sharedUserId and are signed with the same keys, they will use + * the same AndroidKeyStore and may obtain access to the files and shared preferences + * of other applications by invoking createPackageContext. + * + * Support for sharedUserId is deprecated, however some applications still use this Android capability. + * See: https://developer.android.com/guide/topics/manifest/manifest-element + * + * To address apps in this scenario we will attempt to load an existing KeyPair + * instead of immediately generating a new key pair. This will use the same keypair + * to encrypt the symmetric key generated separately for each + * application using a shared linux user id... and avoid these applications from + * stomping/overwriting one another's keypair. + */ + val methodTag = "$TAG:generateAndStoreSecretKey" + Logger.info(methodTag, "Generating a new SecretKey") + val newSecretKey = generateRandomKey() + + var keyPair = AndroidKeyStoreUtil.readKey(alias) + Logger.info(methodTag, "KeyPair is null: ${keyPair == null}") + if (keyPair == null) { + keyPair = generateKeyPair() + } + + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + Logger.info(TAG, "Wrapping key with cipher: $cipherParamsSpec") + val keyWrapped = AndroidKeyStoreUtil.wrap( + newSecretKey, + keyPair, + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec + ) + + FileUtil.writeDataToFile(keyWrapped, keyFile) + Logger.info(methodTag, "New key is generated with thumbprint: " + KeyUtil.getKeyThumbPrint(newSecretKey)) + return newSecretKey + } + + + + + + /* package */@Synchronized + @Throws(ClientException::class) + fun readSecretKeyFromStorage(): SecretKey? { + val methodTag = "$TAG:readSecretKeyFromStorage" + try { + val keyPair = AndroidKeyStoreUtil.readKey(alias) + if (keyPair == null) { + Logger.info(methodTag, "key does not exist in keystore") + deleteSecretKeyAndCleanup() + return null + } + + val wrappedSecretKey = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) + if (wrappedSecretKey == null) { + Logger.warn(methodTag, "Key file is empty") + // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache + // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. + FileUtil.deleteFile(keyFile) + clearKeyFromCache() + return null + } + + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + val secretKey = AndroidKeyStoreUtil.unwrap( + wrappedSecretKey, + WRAP_KEY_ALGORITHM, + keyPair, + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec + ) + Logger.info(methodTag, "secretkey algorithm: ${secretKey.algorithm}, " + + "key size: ${secretKey.encoded.size}, " + + "key thumbprint: ${KeyUtil.getKeyThumbPrint(secretKey)}" + ) + return secretKey + } catch (e: ClientException) { + // Reset KeyPair info so that new request will generate correct KeyPairs. + // All tokens with previous SecretKey are not possible to decrypt. + Logger.warn( + methodTag, "Error when loading key from Storage, " + + "wipe all existing key data " + ) + deleteSecretKeyAndCleanup() + throw e + } + } + + + + + /** + * Selects the most appropriate [CipherSpec] for the given [KeyPair] by matching the supported + * encryption paddings from the Android Keystore with a prioritized list of available cipher specs. + * + * This function attempts to find a compatible cipher configuration for key wrapping by: + * 1. Fetching the encryption paddings supported by the provided [keyPair]. + * 2. Iterating through the prioritized list of [CipherSpec]s. + * 3. Returning the first compatible spec where the padding is supported by the key. + * + * If no matching specification is found, a fallback using PKCS#1 padding is returned. + * + * @param keyPair The [KeyPair] for which a compatible [CipherSpec] should be determined. + * @return A compatible [CipherSpec], or a fallback to a PKCS#1-based spec if none are supported. + */ + private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { + val methodTag = "$TAG:selectCompatibleCipherSpec" + val supportedPaddings = AndroidKeyStoreUtil.getEncryptionPaddings(keyPair) + val availableSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() + Logger.info(TAG, + "Supported paddings by the keyPair: $supportedPaddings" + + ",Specs available in order of priority: $availableSpecs" + ) + for (spec in availableSpecs) { + for (padding in supportedPaddings) { + if (spec.padding.contains(padding, ignoreCase = true)) { + return spec + } + } + } + Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") + // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. + return cryptoParameterSpecFactory.getPkcs1CipherSpec() + } + + /** + * Generates a new RSA key pair and stores it in the Android KeyStore. + * + * + * This method attempts to generate a key pair using multiple key generation specifications + * in order of preference. If the primary specification fails, it will attempt fallback + * specifications to ensure compatibility across different Android versions and devices. + * + * + * The key generation process is traced using OpenTelemetry for monitoring and diagnostics. + * + * @return A new RSA KeyPair stored in the Android KeyStore + * @throws ClientException if all key generation attempts fail + */ + @Throws(ClientException::class) + private fun generateKeyPair(): KeyPair { + val methodTag = "${TAG}:generateKeyPair" + val span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name, SpanExtension.current().spanContext) + val failures = mutableListOf() + val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() + + try { + SpanExtension.makeCurrentSpan(span).use { _ -> + for (spec in specs) { + try { + val keypairGenStartTime = System.currentTimeMillis() + val keyPair = AndroidKeyStoreUtil.generateKeyPair( + spec.algorithm, + spec.algorithmParameterSpec + ) + val elapsedTime = System.currentTimeMillis() - keypairGenStartTime + SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) + span.setStatus(StatusCode.OK) + Log.i(TAG, "Key pair generated successfully with spec: $spec ") + return keyPair + } catch (throwable: Throwable) { + Logger.warn(methodTag, "Failed to generate key pair with spec: $spec") + failures.add(throwable) + } + } + + // If we reach here, all attempts have failed + failures.forEach { exception -> + Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) + } + val finalError = failures.lastOrNull() ?: ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "Key pair generation failed after trying all available specs." + ) + span.setStatus(StatusCode.ERROR) + span.recordException(finalError) + throw ExceptionAdapter.clientExceptionFromException(finalError) + } + } finally { + span.end() + } + } + + companion object { + private val TAG = KeyStoreBackedSecretKeyProvider::class.java.simpleName + + /** + * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in + * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here + * probably doing PKCS7. We decide to go with Java default string. + */ + const val AES_CIPHER_TRANSFORMATION: String = "AES/CBC/PKCS5Padding" + + /** + * Should KeyStore and key file check for validity before every key load be skipped. + */ + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + var sSkipKeyInvalidationCheck: Boolean = false + + /** + * Algorithm for the wrapping key itself. + */ + private const val WRAP_KEY_ALGORITHM = "RSA" + + /** + * Indicate that token item is encrypted with the key loaded in this class. + */ + const val WRAPPED_KEY_KEY_IDENTIFIER : String = "A001" + + @VisibleForTesting + const val KEY_FILE_SIZE: Int = 1024 + + /** + * SecretKey cache. Maps wrapped secret key file path to the SecretKey. + */ + private val sKeyCacheMap: ConcurrentMap = ConcurrentHashMap() + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java new file mode 100644 index 0000000000..9db8a20e39 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import static com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator.AES_ALGORITHM; + +import android.content.Context; +import android.os.Build; +import android.security.KeyPairGeneratorSpec; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import androidx.annotation.RequiresApi; + +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.controllers.ExceptionAdapter; +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.KeyUtil; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.opentelemetry.AttributeName; +import com.microsoft.identity.common.java.opentelemetry.OTelUtility; +import com.microsoft.identity.common.java.opentelemetry.SpanExtension; +import com.microsoft.identity.common.java.opentelemetry.SpanName; +import com.microsoft.identity.common.java.util.CachedData; +import com.microsoft.identity.common.java.util.FileUtil; +import com.microsoft.identity.common.java.util.StringUtil; +import com.microsoft.identity.common.logging.Logger; + +import java.io.File; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.spec.AlgorithmParameterSpec; +import java.util.Calendar; +import java.util.Locale; + +import javax.crypto.SecretKey; +import javax.security.auth.x500.X500Principal; + +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import lombok.NonNull; + +/** + * This class doesn't really use the KeyStore-generated key directly. + *

+ * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key + * before it get saved to the file. + */ +public class AndroidWrappedKeyProvider implements ISecretKeyProvider { + + /** + * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in + * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here + * probably doing PKCS7. We decide to go with Java default string. + */ + private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; + + private static final String TAG = AndroidWrappedKeyProvider.class.getSimpleName() + "#"; + + /** + * Should KeyStore and key file check for validity before every key load be skipped. + */ + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + public static boolean sSkipKeyInvalidationCheck = false; + + /** + * Algorithm for key wrapping. + */ + private static final String WRAP_ALGORITHM = "RSA/ECB/PKCS1Padding"; + + /** + * Algorithm for the wrapping key itself. + */ + private static final String WRAP_KEY_ALGORITHM = "RSA"; + + /** + * Indicate that token item is encrypted with the key loaded in this class. + */ + public static final String WRAPPED_KEY_KEY_IDENTIFIER = "A001"; + + // Exposed for testing only. + /* package */ static final int KEY_FILE_SIZE = 1024; + + private final Context mContext; + + /** + * Name of the key itself. Must be unique. + */ + private final String mAlias; + + /** + * Name of the file contains the wrapped symmetric key used for encryption/decryption. + * Must be unique. + */ + private final String mFilePath; + + private final CachedData mKeyCache = new CachedData() { + @Override + public SecretKey getData() { + if (!sSkipKeyInvalidationCheck && + (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !getKeyFile().exists())) { + this.clear(); + } + return super.getData(); + } + }; + + // Exposed for testing only. + @NonNull + /* package */ CachedData getKeyCache() { + return mKeyCache; + } + + /** + * Default constructor + * + * @param alias Alias(name) of the wrapping key. + * @param filePath Path to the file for storing the wrapped key. + * @param context Android's {@link Context} + */ + public AndroidWrappedKeyProvider(@NonNull final String alias, + @NonNull final String filePath, + @NonNull final Context context) { + mAlias = alias; + mFilePath = filePath; + mContext = context; + } + + @Override + @NonNull + public String getAlias() { + return mAlias; + } + + @Override + @NonNull + public String getKeyTypeIdentifier() { + return WRAPPED_KEY_KEY_IDENTIFIER; + } + + /** + * If key is already generated, that one will be returned. + * Otherwise, generate a new one and return. + */ + @Override + @NonNull + public synchronized SecretKey getKey() throws ClientException { + SecretKey key = mKeyCache.getData(); + + if (key == null) { + key = readSecretKeyFromStorage(); + } + + // If key doesn't exist, generate a new one. + if (key == null) { + key = generateRandomKey(); + } + + mKeyCache.setData(key); + return key; + } + + @NonNull + protected SecretKey generateRandomKey() throws ClientException { + final String methodTag = TAG + ":generateRandomKey"; + + final SecretKey key = AES256SecretKeyGenerator.INSTANCE.generateRandomKey(); + saveSecretKeyToStorage(key); + + Logger.info(methodTag, "New key is generated with thumbprint: " + + KeyUtil.getKeyThumbPrint(key)); + + return key; + } + + /** + * Load the saved keystore-encrypted key. Will only do read operation. + * + * @return SecretKey. Null if there isn't any. + */ + @Nullable + /* package */ synchronized SecretKey readSecretKeyFromStorage() throws ClientException { + final String methodTag = TAG + ":readSecretKeyFromStorage"; + try { + final KeyPair keyPair = AndroidKeyStoreUtil.readKey(mAlias); + if (keyPair == null) { + Logger.info(methodTag, "key does not exist in keystore"); + deleteSecretKeyFromStorage(); + return null; + } + + final byte[] wrappedSecretKey = FileUtil.readFromFile(getKeyFile(), KEY_FILE_SIZE); + if (wrappedSecretKey == null) { + Logger.warn(methodTag, "Key file is empty"); + // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache + // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. + FileUtil.deleteFile(getKeyFile()); + mKeyCache.clear(); + return null; + } + + final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, AES_ALGORITHM, keyPair, WRAP_ALGORITHM); + + Logger.info(methodTag, "Key is loaded with thumbprint: " + + KeyUtil.getKeyThumbPrint(key)); + + return key; + } catch (final ClientException e) { + // Reset KeyPair info so that new request will generate correct KeyPairs. + // All tokens with previous SecretKey are not possible to decrypt. + Logger.warn(methodTag, "Error when loading key from Storage, " + + "wipe all existing key data "); + deleteSecretKeyFromStorage(); + throw e; + } + } + + /** + * Encrypt the given unencrypted symmetric key with Keystore key and save to storage. + */ + private void saveSecretKeyToStorage(@NonNull final SecretKey unencryptedKey) throws ClientException { + final String methodTag = TAG + ":saveSecretKeyToStorage"; + /* + * !!WARNING!! + * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring + * the sharedUserId attribute in their Android Manifest file. If multiple apps reference + * the same value for sharedUserId and are signed with the same keys, they will use + * the same AndroidKeyStore and may obtain access to the files and shared preferences + * of other applications by invoking createPackageContext. + * + * Support for sharedUserId is deprecated, however some applications still use this Android capability. + * See: https://developer.android.com/guide/topics/manifest/manifest-element + * + * To address apps in this scenario we will attempt to load an existing KeyPair + * instead of immediately generating a new key pair. This will use the same keypair + * to encrypt the symmetric key generated separately for each + * application using a shared linux user id... and avoid these applications from + * stomping/overwriting one another's keypair. + */ + KeyPair keyPair = AndroidKeyStoreUtil.readKey(mAlias); + if (keyPair == null) { + Logger.info(methodTag, "No existing keypair. Generating a new one."); + final Span span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name(), SpanExtension.current().getSpanContext()); + try (final Scope ignored = SpanExtension.makeCurrentSpan(span)) { + keyPair = generateNewKeyPair(); + span.setStatus(StatusCode.OK); + } catch (final ClientException e) { + span.setStatus(StatusCode.ERROR); + span.recordException(e); + throw e; + } finally { + span.end(); + } + } + final byte[] keyWrapped = AndroidKeyStoreUtil.wrap(unencryptedKey, keyPair, WRAP_ALGORITHM); + FileUtil.writeDataToFile(keyWrapped, getKeyFile()); + } + + /** + * Generate a new key pair wrapping key, based on API level uses different spec to generate + * the key pair. + * @return a key pair + */ + @NonNull + private KeyPair generateNewKeyPair() throws ClientException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return generateNewKeyPairAPI28AndAbove(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return generateNewKeyPairAPI23AndAbove(); + } else { + return generateKeyPairWithLegacySpec(); + } + } + + /** + * Call this for API level >= 28. Starting level API 28 PURPOSE_WRAP_KEY is added. Based on flights + * this method may or may not use the PURPOSE_WRAP_KEY along with PURPOSE_ENCRYPT and PURPOSE_DECRYPT. The logic + * if (wrap key flight enabled) use all three purposes + * else if (new key gen flight enabled) use only encrypt and decrypt purposes + * else use legacy spec. + * @return key pair + */ + @RequiresApi(Build.VERSION_CODES.P) + @NonNull + private KeyPair generateNewKeyPairAPI28AndAbove() throws ClientException { + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) { + return generateWrappingKeyPair_WithPurposeWrapKey(); + } else if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) { + return generateWrappingKeyPair(); + } else { + return generateKeyPairWithLegacySpec(); + } + } + + /** + * Call this for API level >= 23. Based on flight new key gen spec is used else legacy which + * is deprecated starting API 23. + * @return key pair + */ + @RequiresApi(Build.VERSION_CODES.M) + @NonNull + private KeyPair generateNewKeyPairAPI23AndAbove() throws ClientException { + if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) { + return generateWrappingKeyPair(); + } else { + return generateKeyPairWithLegacySpec(); + } + } + + /** + * Generate a new key pair wrapping key based on legacy logic. Call this for API < 23 or as fallback + * until new key gen specs are stable. + * @return key pair generated with legacy spec + * @throws ClientException if there is an error generating the key pair. + */ + @NonNull + private KeyPair generateKeyPairWithLegacySpec() throws ClientException{ + final Span span = SpanExtension.current(); + try { + final AlgorithmParameterSpec keyPairGenSpec = getLegacySpecForKeyStoreKey(); + final KeyPair keyPair = attemptKeyPairGeneration(keyPairGenSpec); + span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), "legacy_key_gen_spec"); + return keyPair; + } catch (final Throwable e) { + Logger.error(TAG + ":generateKeyPairWithLegacySpec", "Error generating keypair with legacy spec.", e); + throw ExceptionAdapter.clientExceptionFromException(e); + } + } + + /** + * Generate a new key pair wrapping key, based on API level >= 28. This method uses new key gen spec + * with PURPOSE_WRAP_KEY. If this fails, it will fallback to generateWrappingKeyPair() which does not use + * PURPOSE_WRAP_KEY (still uses new key gen spec). + */ + @RequiresApi(Build.VERSION_CODES.P) + private KeyPair generateWrappingKeyPair_WithPurposeWrapKey() throws ClientException { + final String methodTag = TAG + ":generateWrappingKeyPair_WithPurposeWrapKey"; + final Span span = SpanExtension.current(); + try { + Logger.info(methodTag, "Generating new keypair with new spec with purpose_wrap_key"); + int purposes = KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_WRAP_KEY; + final AlgorithmParameterSpec keyPairGenSpec = getSpecForWrappingKey(purposes); + final KeyPair keyPair = attemptKeyPairGeneration(keyPairGenSpec); + span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), "new_key_gen_spec_with_wrap"); + return keyPair; + } catch (final Throwable e) { + Logger.error(methodTag, "Error generating keypair with new spec with purpose_wrap_key." + + "Attempting without purpose_wrap_key." , e); + if (!StringUtil.isNullOrEmpty(e.getMessage())) { + span.setAttribute(AttributeName.keypair_gen_exception.name(), e.getMessage()); + } + return generateWrappingKeyPair(); + } + } + + /** + * Generate a new key pair wrapping key, based on API level >= 23. This method uses new key gen spec + * with purposes PURPOSE_ENCRYPT and PURPOSE_DECRYPT. If this fails, it will fallback to generateKeyPairWithLegacySpec() + * which uses olg key gen spec. + */ + @RequiresApi(Build.VERSION_CODES.M) + private KeyPair generateWrappingKeyPair() throws ClientException { + final String methodTag = TAG + ":generateWrappingKeyPair"; + final Span span = SpanExtension.current(); + try { + Logger.info(methodTag, "Generating new keypair with new spec without wrap key"); + int purposes = KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT; + final AlgorithmParameterSpec keyPairGenSpec = getSpecForWrappingKey(purposes); + final KeyPair keyPair = attemptKeyPairGeneration(keyPairGenSpec); + span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), "new_key_gen_spec_without_wrap"); + return keyPair; + } catch (final Throwable e) { + Logger.error(methodTag, "Error generating keypair with new spec." + + "Attempting with legacy spec.", e); + if (!StringUtil.isNullOrEmpty(e.getMessage())) { + span.setAttribute(AttributeName.keypair_gen_exception.name(), e.getMessage()); + } + return generateKeyPairWithLegacySpec(); + } + } + + private KeyPair attemptKeyPairGeneration(@NonNull final AlgorithmParameterSpec keyPairGenSpec) throws ClientException{ + final long keypairGenStartTime = System.currentTimeMillis(); + final KeyPair keyPair = AndroidKeyStoreUtil.generateKeyPair( + WRAP_KEY_ALGORITHM, keyPairGenSpec); + recordKeyGenerationTime(keypairGenStartTime); + return keyPair; + } + + private void recordKeyGenerationTime(long keypairGenStartTime) { + long elapsedTime = System.currentTimeMillis() - keypairGenStartTime; + SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name(), elapsedTime); + } + + /** + * Wipe all the data associated from this key. + */ + // VisibleForTesting + public void deleteSecretKeyFromStorage() throws ClientException { + AndroidKeyStoreUtil.deleteKey(mAlias); + FileUtil.deleteFile(getKeyFile()); + mKeyCache.clear(); + } + + /** + * Generate a self-signed cert and derive an AlgorithmParameterSpec from that. + * This is for the key to be generated in {@link KeyStore} via {@link KeyPairGenerator} + * Note : This is now only for API level < 23 or as fallback. + + * @return a {@link AlgorithmParameterSpec} for the keystore key (that we'll use to wrap the secret key). + */ + private AlgorithmParameterSpec getLegacySpecForKeyStoreKey() { + // Generate a self-signed cert. + final String certInfo = String.format(Locale.ROOT, "CN=%s, OU=%s", + mAlias, + mContext.getPackageName()); + + final Calendar start = Calendar.getInstance(); + final Calendar end = Calendar.getInstance(); + final int certValidYears = 100; + end.add(Calendar.YEAR, certValidYears); + + return new KeyPairGeneratorSpec.Builder(mContext) + .setAlias(mAlias) + .setSubject(new X500Principal(certInfo)) + .setSerialNumber(BigInteger.ONE) + .setStartDate(start.getTime()) + .setEndDate(end.getTime()) + .build(); + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private AlgorithmParameterSpec getSpecForWrappingKey(int purposes) { + return new KeyGenParameterSpec.Builder(mAlias, purposes) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .build(); + } + + /** + * Get the file that stores the wrapped key. + */ + private File getKeyFile() { + return new File( + mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), + mFilePath); + } + + @NonNull + @Override + public String getCipherTransformation() { + return CIPHER_TRANSFORMATION; + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index 8a0f7b7500..6c84a33118 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -501,6 +501,7 @@ public static synchronized List getEncryptionPaddings(@NonNull final Key for (final String padding : keyInfo.getEncryptionPaddings()) { encryptionPaddings.add(padding.replace("Padding", "")); } + Logger.info(methodTag, "Supported encryption paddings: " + encryptionPaddings); return encryptionPaddings; } else { Logger.warn(methodTag, "getKeyInfo is not supported on this Android version"); diff --git a/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java b/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java new file mode 100644 index 0000000000..cfe0bb19e5 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java @@ -0,0 +1,658 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.internal.util; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import android.content.Context; +import android.os.Build; +import android.security.KeyPairGeneratorSpec; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyInfo; +import android.security.keystore.KeyProperties; + +import com.microsoft.identity.common.java.exception.ClientException; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.spec.InvalidKeySpecException; +import java.util.Calendar; +import java.util.List; +import java.util.Locale; + +import javax.crypto.SecretKey; +import javax.crypto.spec.OAEPParameterSpec; +import javax.security.auth.x500.X500Principal; + +/** + * Unit tests for {@link AndroidKeyStoreUtil} + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {Build.VERSION_CODES.P}) // Targeting Android 9.0 (API 28) for the tests +public class AndroidKeyStoreUtilTest { + + private static final String TEST_KEY_ALIAS = "test_key_alias"; + private static final String TEST_SECRET_KEY_ALGORITHM = "AES"; + private static final String RSA_ALGORITHM = "RSA"; + private static final String TRANSFORMATION_RSA_ECB_PKCS1 = "RSA/ECB/PKCS1Padding"; + private static final String TRANSFORMATION_RSA_OAEP = "RSA/NONE/OAEPwithSHA-256andMGF1Padding"; + private static final String ANDROID_KEYSTORE_PROVIDER = "AndroidKeyStore"; + + @Mock + private Context mockContext; + + @Mock + private KeyPair mockKeyPair; + + @Mock + private PrivateKey mockPrivateKey; + + @Mock + private PublicKey mockPublicKey; + + @Mock + private Certificate mockCertificate; + + @Mock + private SecretKey mockSecretKey; + + @Mock + private KeyFactory mockKeyFactory; + + @Mock + private KeyInfo mockKeyInfo; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + + // Setup basic mock behavior + when(mockKeyPair.getPrivate()).thenReturn(mockPrivateKey); + when(mockKeyPair.getPublic()).thenReturn(mockPublicKey); + when(mockCertificate.getPublicKey()).thenReturn(mockPublicKey); + when(mockPrivateKey.getAlgorithm()).thenReturn(RSA_ALGORITHM); + } + + // Mock-only tests for methods that are too complex to test the real implementation + @Test + public void testCanLoadKey_KeyExists_ReturnsTrue() { + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.canLoadKey(TEST_KEY_ALIAS)) + .thenReturn(true); + + boolean result = AndroidKeyStoreUtil.canLoadKey(TEST_KEY_ALIAS); + assertTrue(result); + } + } + + @Test + public void testCanLoadKey_KeyDoesNotExist_ReturnsFalse() { + // Arrange + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.canLoadKey(TEST_KEY_ALIAS)) + .thenReturn(false); + + // Act + boolean result = AndroidKeyStoreUtil.canLoadKey(TEST_KEY_ALIAS); + + // Assert + assertFalse(result); + } + } + + @Test + public void testReadKey_KeyExists_ReturnsKeyPair() throws Exception { + // Arrange + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.readKey(TEST_KEY_ALIAS)) + .thenReturn(mockKeyPair); + + // Act + KeyPair result = AndroidKeyStoreUtil.readKey(TEST_KEY_ALIAS); + + // Assert + assertNotNull(result); + assertEquals(mockKeyPair, result); + } + } + + @Test + public void testReadKey_KeyDoesNotExist_ReturnsNull() throws Exception { + // Arrange + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.readKey(TEST_KEY_ALIAS)) + .thenReturn(null); + + // Act + KeyPair result = AndroidKeyStoreUtil.readKey(TEST_KEY_ALIAS); + + // Assert + assertNull(result); + } + } + + @Test + public void testDeleteKey_Success() throws Exception { + // Arrange + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.deleteKey(TEST_KEY_ALIAS)) + .thenAnswer(invocation -> null); + + // Act & Assert - Should not throw any exception + AndroidKeyStoreUtil.deleteKey(TEST_KEY_ALIAS); + } + } + + @Test(expected = ClientException.class) + public void testDeleteKey_KeyStoreException_ThrowsClientException() throws Exception { + // Arrange + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.deleteKey(TEST_KEY_ALIAS)) + .thenThrow(new ClientException(ClientException.ANDROID_KEYSTORE_UNAVAILABLE, "KeyStore exception")); + + // Act + AndroidKeyStoreUtil.deleteKey(TEST_KEY_ALIAS); + } + } + + @Test + public void testGenerateKeyPair_WithLegacySpec_Success() throws Exception { + // Arrange + KeyPairGeneratorSpec legacySpec = createLegacyKeyPairGeneratorSpec(); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.generateKeyPair(RSA_ALGORITHM, legacySpec)) + .thenReturn(mockKeyPair); + + // Act + KeyPair result = AndroidKeyStoreUtil.generateKeyPair(RSA_ALGORITHM, legacySpec); + + // Assert + assertNotNull(result); + assertEquals(mockKeyPair, result); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) + public void testGenerateKeyPair_WithModernSpec_Success() throws Exception { + // Arrange + KeyGenParameterSpec modernSpec = createModernKeyGenParameterSpec(); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.generateKeyPair(RSA_ALGORITHM, modernSpec)) + .thenReturn(mockKeyPair); + + // Act + KeyPair result = AndroidKeyStoreUtil.generateKeyPair(RSA_ALGORITHM, modernSpec); + + // Assert + assertNotNull(result); + assertEquals(mockKeyPair, result); + } + } + + @Test(expected = ClientException.class) + public void testGenerateKeyPair_KeyStoreUnavailable_ThrowsClientException() throws Exception { + // Arrange + KeyPairGeneratorSpec spec = createLegacyKeyPairGeneratorSpec(); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.generateKeyPair(RSA_ALGORITHM, spec)) + .thenThrow(new ClientException(ClientException.ANDROID_KEYSTORE_UNAVAILABLE, "KeyStore unavailable")); + + // Act + AndroidKeyStoreUtil.generateKeyPair(RSA_ALGORITHM, spec); + } + } + + @Test + public void testWrap_WithPKCS1Padding_Success() throws Exception { + // Arrange + byte[] expectedWrappedKey = "wrapped_key_data".getBytes(); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.wrap(mockSecretKey, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null)) + .thenReturn(expectedWrappedKey); + + // Act + byte[] result = AndroidKeyStoreUtil.wrap(mockSecretKey, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null); + + // Assert + assertNotNull(result); + assertArrayEquals(expectedWrappedKey, result); + } + } + + @Test + public void testWrap_WithOAEPPadding_Success() throws Exception { + // Arrange + byte[] expectedWrappedKey = "wrapped_key_data_oaep".getBytes(); + OAEPParameterSpec oaepSpec = new OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA1, javax.crypto.spec.PSource.PSpecified.DEFAULT); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.wrap(mockSecretKey, mockKeyPair, TRANSFORMATION_RSA_OAEP, oaepSpec)) + .thenReturn(expectedWrappedKey); + + // Act + byte[] result = AndroidKeyStoreUtil.wrap(mockSecretKey, mockKeyPair, TRANSFORMATION_RSA_OAEP, oaepSpec); + + // Assert + assertNotNull(result); + assertArrayEquals(expectedWrappedKey, result); + } + } + + @Test(expected = ClientException.class) + public void testWrap_InvalidKey_ThrowsClientException() throws Exception { + // Arrange + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.wrap(mockSecretKey, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null)) + .thenThrow(new ClientException(ClientException.INVALID_KEY, "Invalid key")); + + // Act + AndroidKeyStoreUtil.wrap(mockSecretKey, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null); + } + } + + @Test + public void testUnwrap_WithPKCS1Padding_Success() throws Exception { + // Arrange + byte[] wrappedKeyData = "wrapped_key_data".getBytes(); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.unwrap(wrappedKeyData, TEST_SECRET_KEY_ALGORITHM, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null)) + .thenReturn(mockSecretKey); + + // Act + SecretKey result = AndroidKeyStoreUtil.unwrap(wrappedKeyData, TEST_SECRET_KEY_ALGORITHM, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null); + + // Assert + assertNotNull(result); + assertEquals(mockSecretKey, result); + } + } + + @Test + public void testUnwrap_WithOAEPPadding_Success() throws Exception { + // Arrange + byte[] wrappedKeyData = "wrapped_key_data_oaep".getBytes(); + OAEPParameterSpec oaepSpec = new OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA1, javax.crypto.spec.PSource.PSpecified.DEFAULT); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.unwrap(wrappedKeyData, TEST_SECRET_KEY_ALGORITHM, mockKeyPair, TRANSFORMATION_RSA_OAEP, oaepSpec)) + .thenReturn(mockSecretKey); + + // Act + SecretKey result = AndroidKeyStoreUtil.unwrap(wrappedKeyData, TEST_SECRET_KEY_ALGORITHM, mockKeyPair, TRANSFORMATION_RSA_OAEP, oaepSpec); + + // Assert + assertNotNull(result); + assertEquals(mockSecretKey, result); + } + } + + @Test(expected = ClientException.class) + public void testUnwrap_KeyStoreUnavailable_ThrowsClientException() throws Exception { + // Arrange + byte[] wrappedKeyData = "wrapped_key_data".getBytes(); + + try (MockedStatic mockedStatic = mockStatic(AndroidKeyStoreUtil.class)) { + mockedStatic.when(() -> AndroidKeyStoreUtil.unwrap(wrappedKeyData, TEST_SECRET_KEY_ALGORITHM, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null)) + .thenThrow(new ClientException(ClientException.ANDROID_KEYSTORE_UNAVAILABLE, "KeyStore unavailable")); + + // Act + AndroidKeyStoreUtil.unwrap(wrappedKeyData, TEST_SECRET_KEY_ALGORITHM, mockKeyPair, TRANSFORMATION_RSA_ECB_PKCS1, null); + } + } + + // Real implementation tests for getEncryptionPaddings + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_ReturnsProcessedPaddings() throws Exception { + // Arrange + String[] paddingsFromKeyInfo = {"RSA_PKCS1Padding", "RSA_OAEPPadding"}; + + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenReturn(mockKeyFactory); + when(mockKeyFactory.getKeySpec(mockPrivateKey, KeyInfo.class)) + .thenReturn(mockKeyInfo); + when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); + + // Act - Call the REAL method, not mocked + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert - Verify the actual processing logic worked + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("RSA_PKCS1", result.get(0)); // Verify "Padding" suffix was stripped + assertEquals("RSA_OAEP", result.get(1)); // Verify "Padding" suffix was stripped + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_SinglePadding() throws Exception { + // Arrange + String[] paddingsFromKeyInfo = {"RSA_PKCS1Padding"}; + + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenReturn(mockKeyFactory); + when(mockKeyFactory.getKeySpec(mockPrivateKey, KeyInfo.class)) + .thenReturn(mockKeyInfo); + when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("RSA_PKCS1", result.get(0)); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_EmptyPaddingsArray() throws Exception { + // Arrange + String[] paddingsFromKeyInfo = {}; + + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenReturn(mockKeyFactory); + when(mockKeyFactory.getKeySpec(mockPrivateKey, KeyInfo.class)) + .thenReturn(mockKeyInfo); + when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_KeyFactoryException_ReturnsEmptyList() { + // Arrange + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenThrow(new NoSuchAlgorithmException("Algorithm not found")); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_KeySpecException_ReturnsEmptyList() throws Exception { + // Arrange + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenReturn(mockKeyFactory); + when(mockKeyFactory.getKeySpec(mockPrivateKey, KeyInfo.class)) + .thenThrow(new InvalidKeySpecException("Invalid key spec")); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_NoSuchProviderException_ReturnsEmptyList() { + // Arrange + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenThrow(new NoSuchProviderException("Provider not found")); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_RuntimeException_ReturnsEmptyList() throws Exception { + // Arrange + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenReturn(mockKeyFactory); + when(mockKeyFactory.getKeySpec(mockPrivateKey, KeyInfo.class)) + .thenThrow(new RuntimeException("Unexpected runtime error")); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.LOLLIPOP) // API 21, before M + public void testGetEncryptionPaddings_LegacyAPI_ReturnsEmptyList() { + // Act - Call the REAL method on legacy API + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert - Should return empty list because API < 23 + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @Config(sdk = Build.VERSION_CODES.KITKAT) // API 19, before M + public void testGetEncryptionPaddings_VeryLegacyAPI_ReturnsEmptyList() { + // Act - Call the REAL method on very legacy API + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert - Should return empty list because API < 23 + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_PaddingsWithoutSuffix_ReturnedAsIs() throws Exception { + // Arrange - Test case where paddings don't have "Padding" suffix + String[] paddingsFromKeyInfo = {"RSA_PKCS1", "RSA_OAEP", "SomethingElse"}; + + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenReturn(mockKeyFactory); + when(mockKeyFactory.getKeySpec(mockPrivateKey, KeyInfo.class)) + .thenReturn(mockKeyInfo); + when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert - Should return as-is since no "Padding" suffix to strip + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("RSA_PKCS1", result.get(0)); + assertEquals("RSA_OAEP", result.get(1)); + assertEquals("SomethingElse", result.get(2)); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 + public void testGetEncryptionPaddings_ModernAPI_MixedPaddingFormats() throws Exception { + // Arrange - Test mix of paddings with and without "Padding" suffix + String[] paddingsFromKeyInfo = {"RSA_PKCS1Padding", "RSA_OAEP", "AESPadding"}; + + try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { + keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) + .thenReturn(mockKeyFactory); + when(mockKeyFactory.getKeySpec(mockPrivateKey, KeyInfo.class)) + .thenReturn(mockKeyInfo); + when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); + + // Act + List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + + // Assert - Should strip "Padding" where present, leave others as-is + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("RSA_PKCS1", result.get(0)); // "Padding" stripped + assertEquals("RSA_OAEP", result.get(1)); // No change + assertEquals("AES", result.get(2)); // "Padding" stripped + } + } + + // Tests for applyKeyStoreLocaleWorkarounds - testing real implementation + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 (≤ M) + public void testApplyKeyStoreLocaleWorkarounds_NonGregorianLocale_ChangesToEnglish() { + // Arrange + Locale originalLocale = Locale.getDefault(); + Locale nonGregorianLocale = Locale.forLanguageTag("ar-SA"); // Arabic locale, uses Hijri calendar + + try { + // Set to non-Gregorian locale first + Locale.setDefault(nonGregorianLocale); + + // Act - Call the REAL method + AndroidKeyStoreUtil.applyKeyStoreLocaleWorkarounds(nonGregorianLocale); + + // Assert - On API ≤ M with non-Gregorian calendar, should change to English + assertEquals(Locale.ENGLISH, Locale.getDefault()); + } finally { + // Cleanup + Locale.setDefault(originalLocale); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) // API 23 (≤ M) + public void testApplyKeyStoreLocaleWorkarounds_GregorianLocale_NoChange() { + // Arrange + Locale originalLocale = Locale.getDefault(); + Locale gregorianLocale = Locale.US; // Uses Gregorian calendar + + try { + // Set to Gregorian locale first + Locale.setDefault(gregorianLocale); + + // Act - Call the REAL method + AndroidKeyStoreUtil.applyKeyStoreLocaleWorkarounds(gregorianLocale); + + // Assert - Should not change for Gregorian calendar + assertEquals(gregorianLocale, Locale.getDefault()); + } finally { + // Cleanup + Locale.setDefault(originalLocale); + } + } + + @Test + @Config(sdk = Build.VERSION_CODES.N) // API 24 (> M) + public void testApplyKeyStoreLocaleWorkarounds_NewerAPI_NoChange() { + // Arrange + Locale originalLocale = Locale.getDefault(); + Locale nonGregorianLocale = new Locale("th", "TH", "TH"); // Thai Buddhist calendar + + try { + // Set to non-Gregorian locale first + Locale.setDefault(nonGregorianLocale); + + // Act - Call the REAL method + AndroidKeyStoreUtil.applyKeyStoreLocaleWorkarounds(nonGregorianLocale); + + // Assert - On API > M, no locale change should occur regardless of calendar + assertEquals(nonGregorianLocale, Locale.getDefault()); + } finally { + // Cleanup + Locale.setDefault(originalLocale); + } + } + + /** + * Helper method to create a legacy KeyPairGeneratorSpec for testing + */ + private KeyPairGeneratorSpec createLegacyKeyPairGeneratorSpec() { + Calendar start = Calendar.getInstance(); + Calendar end = Calendar.getInstance(); + end.add(Calendar.YEAR, 100); + + return new KeyPairGeneratorSpec.Builder(mockContext) + .setAlias(TEST_KEY_ALIAS) + .setSubject(new X500Principal("CN=" + TEST_KEY_ALIAS + ", OU=test")) + .setSerialNumber(BigInteger.ONE) + .setStartDate(start.getTime()) + .setEndDate(end.getTime()) + .build(); + } + + /** + * Helper method to create a modern KeyGenParameterSpec for testing + */ + @Config(sdk = Build.VERSION_CODES.M) + private KeyGenParameterSpec createModernKeyGenParameterSpec() { + return new KeyGenParameterSpec.Builder( + TEST_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .build(); + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index 9c5f2ddbef..c3150e30e5 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -127,9 +127,9 @@ public enum CommonFlight implements IFlightConfig { ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING("EnableOAEPWithSHAAndMGF1Padding", true), /** - * Flight to enable the new Android wrapped key loader. + * Flight to enable the OAEP Android Wrapped Key Loader. */ - ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER("EnableNewAndroidWrappedKeyLoader", true), + ENABLE_OAEP_ANDROID_WRAPPED_KEY_LOADER("EnableOaepAndroidWrappedKeyLoader", true), /** * Flight to enable handling the UI in edge to edge mode From f630acedb33ba0736b4b6aaa75e219ffc4ab1092 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 11 Jul 2025 12:26:18 -0700 Subject: [PATCH 17/51] phase2 --- .../crypto/AndroidWrappedKeyProviderTest.java | 46 +++++++------- .../crypto/NewAndroidWrappedKeyProvider.java | 63 +++++++++++-------- 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index f7a5f12291..46be2d5597 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -125,7 +125,7 @@ private AlgorithmParameterSpec getMockKeyPairGeneratorSpec(final String alias) { @Test public void testGenerateKey() throws ClientException { - final AndroidWrappedKeyProvider keyProvider = new AndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyProvider.generateRandomKey(); Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); @@ -133,7 +133,7 @@ public void testGenerateKey() throws ClientException { @Test public void testReadKeyDirectly() throws ClientException { - final OAEPAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); final SecretKey secretKey = keyProvider.getKey(); final SecretKey storedSecretKey = keyProvider.readSecretKeyFromStorage(); @@ -153,12 +153,12 @@ public void testReadKeyDirectly() throws ClientException { public void testLoadKey() throws ClientException { // Nothing exists. This load key function should generate a key if the key hasn't exist. Assert.assertNull(AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); - Assert.assertNull(FileUtil.readFromFile(getKeyFile(), AndroidWrappedKeyProvider.KEY_FILE_SIZE)); + Assert.assertNull(FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE)); - final AndroidWrappedKeyProvider keyProvider = new AndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyProvider.getKey(); - final SecretKey key = keyProvider.getKeyCache().getData(); + final SecretKey key = keyProvider.getKeyFromCache(); Assert.assertNotNull(key); Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); Assert.assertArrayEquals(secretKey.getEncoded(), key.getEncoded()); @@ -168,10 +168,10 @@ public void testLoadKey() throws ClientException { @Test public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientException { // Create a new Keystore-wrapped key. - final AndroidWrappedKeyProvider keyProvider = new AndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); keyProvider.generateRandomKey(); - final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), AndroidWrappedKeyProvider.KEY_FILE_SIZE); + final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); // Overwrite the key file with corrupted data. @@ -195,10 +195,10 @@ public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientExce @Test public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException { // Create a new Keystore-wrapped key. - final AndroidWrappedKeyProvider keyProvider = new AndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); keyProvider.generateRandomKey(); - final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), AndroidWrappedKeyProvider.KEY_FILE_SIZE); + final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); // Overwrite the key file with corrupted data. @@ -223,7 +223,7 @@ public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException @Test @Ignore public void testPerf_WithCachedKey() throws ClientException { - final AndroidWrappedKeyProvider keyProvider = new AndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); long timeStartLoop = System.nanoTime(); for (int i = 0; i < TEST_LOOP; i++) { @@ -238,11 +238,11 @@ public void testPerf_WithCachedKey() throws ClientException { @Test @Ignore public void testPerf_NoCachedKey() throws ClientException { - final AndroidWrappedKeyProvider keyProvider = new AndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); long timeStartLoopNotCached = System.nanoTime(); for (int i = 0; i < 100; i++) { - keyProvider.getKeyCache().clear(); + keyProvider.clearKeyFromCache(); keyProvider.getKey(); } long timeFinishLoopNotCached = System.nanoTime(); @@ -255,23 +255,23 @@ public void testPerf_NoCachedKey() throws ClientException { */ @Test public void testLoadDeletedKeyStoreKey() throws ClientException { - final OAEPAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); // Cached key also be wiped. - final SecretKey key = keyProvider.getKeyCache().getData(); + final SecretKey key = keyProvider.getKeyFromCache(); Assert.assertNull(key); } @Test public void testLoadDeletedKeyFile() throws ClientException { - final OAEPAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); FileUtil.deleteFile(getKeyFile()); // Cached key also be wiped. - final SecretKey key = keyProvider.getKeyCache().getData(); + final SecretKey key = keyProvider.getKeyFromCache(); Assert.assertNull(key); } @@ -315,28 +315,28 @@ public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProv } - private OAEPAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws ClientException { - final OAEPAndroidWrappedKeyProvider keyProvider = new OAEPAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + private NewAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws ClientException { + final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey key = keyProvider.getKey(); Assert.assertNotNull(key); - Assert.assertNotNull(keyProvider.getKeyCache().getData()); + Assert.assertNotNull(keyProvider.getKeyFromCache()); return keyProvider; } /** - * Helper method to generate a random key using OAEPAndroidWrappedKeyProvider. + * Helper method to generate a random key using NewAndroidWrappedKeyProvider. * This method is used to substitute the call to keyLoader.generateRandomKey() in tests. * - * @param keyLoader The OAEPAndroidWrappedKeyProvider instance to use + * @param keyLoader The NewAndroidWrappedKeyProvider instance to use * @return The generated SecretKey * @throws ClientException if key generation fails */ - private SecretKey generateRandomKey(OAEPAndroidWrappedKeyProvider keyLoader) throws ClientException { + private SecretKey generateRandomKey(NewAndroidWrappedKeyProvider keyLoader) throws ClientException { // Get the key will generate a new one if it doesn't exist SecretKey key = keyLoader.getKey(); // Clear the cache to ensure it's regenerated next time - keyLoader.getKeyCache().clear(); + keyLoader.clearKeyFromCache(); // Generate a new key by getting the key again which will create a new one key = keyLoader.getKey(); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java index 9db8a20e39..175fac3501 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java @@ -31,6 +31,7 @@ import android.security.keystore.KeyProperties; import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; import com.microsoft.identity.common.java.controllers.ExceptionAdapter; @@ -44,7 +45,6 @@ import com.microsoft.identity.common.java.opentelemetry.OTelUtility; import com.microsoft.identity.common.java.opentelemetry.SpanExtension; import com.microsoft.identity.common.java.opentelemetry.SpanName; -import com.microsoft.identity.common.java.util.CachedData; import com.microsoft.identity.common.java.util.FileUtil; import com.microsoft.identity.common.java.util.StringUtil; import com.microsoft.identity.common.logging.Logger; @@ -57,6 +57,8 @@ import java.security.spec.AlgorithmParameterSpec; import java.util.Calendar; import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import javax.crypto.SecretKey; import javax.security.auth.x500.X500Principal; @@ -74,7 +76,7 @@ * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key * before it get saved to the file. */ -public class AndroidWrappedKeyProvider implements ISecretKeyProvider { +public class NewAndroidWrappedKeyProvider implements ISecretKeyProvider { /** * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in @@ -83,7 +85,7 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { */ private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; - private static final String TAG = AndroidWrappedKeyProvider.class.getSimpleName() + "#"; + private static final String TAG = NewAndroidWrappedKeyProvider.class.getSimpleName() + "#"; /** * Should KeyStore and key file check for validity before every key load be skipped. @@ -109,6 +111,10 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { // Exposed for testing only. /* package */ static final int KEY_FILE_SIZE = 1024; + /** + * SecretKey cache. Maps wrapped secret key file path to the SecretKey. + */ + private static final ConcurrentMap sKeyCacheMap = new ConcurrentHashMap<>(); private final Context mContext; /** @@ -122,21 +128,17 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { */ private final String mFilePath; - private final CachedData mKeyCache = new CachedData() { - @Override - public SecretKey getData() { - if (!sSkipKeyInvalidationCheck && - (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !getKeyFile().exists())) { - this.clear(); - } - return super.getData(); - } - }; + // Exposed for testing only. + @Nullable + @VisibleForTesting + /* package */ SecretKey getKeyFromCache() { + return sKeyCacheMap.get(mFilePath); + } // Exposed for testing only. - @NonNull - /* package */ CachedData getKeyCache() { - return mKeyCache; + @VisibleForTesting + /* package */ void clearKeyFromCache() { + sKeyCacheMap.remove(mFilePath); } /** @@ -146,7 +148,7 @@ public SecretKey getData() { * @param filePath Path to the file for storing the wrapped key. * @param context Android's {@link Context} */ - public AndroidWrappedKeyProvider(@NonNull final String alias, + public NewAndroidWrappedKeyProvider(@NonNull final String alias, @NonNull final String filePath, @NonNull final Context context) { mAlias = alias; @@ -173,18 +175,27 @@ public String getKeyTypeIdentifier() { @Override @NonNull public synchronized SecretKey getKey() throws ClientException { - SecretKey key = mKeyCache.getData(); + final String methodTag = TAG + ":getKey"; + if (!sSkipKeyInvalidationCheck && + (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !this.getKeyFile().exists())) { + sKeyCacheMap.remove(mFilePath); + } - if (key == null) { - key = readSecretKeyFromStorage(); + SecretKey key = sKeyCacheMap.get(mFilePath); + if (key != null) { + return key; } + Logger.info(methodTag, "Key not in cache or cache is empty, loading key from storage"); + key = readSecretKeyFromStorage(); + // If key doesn't exist, generate a new one. if (key == null) { + Logger.info(methodTag, "Key does not exist in storage, generating a new key"); key = generateRandomKey(); } - mKeyCache.setData(key); + sKeyCacheMap.put(mFilePath, key); return key; } @@ -223,11 +234,11 @@ protected SecretKey generateRandomKey() throws ClientException { // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. FileUtil.deleteFile(getKeyFile()); - mKeyCache.clear(); + sKeyCacheMap.remove(mFilePath); return null; } - final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, AES_ALGORITHM, keyPair, WRAP_ALGORITHM); + final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, AES_ALGORITHM, keyPair, WRAP_ALGORITHM, null); Logger.info(methodTag, "Key is loaded with thumbprint: " + KeyUtil.getKeyThumbPrint(key)); @@ -280,7 +291,7 @@ private void saveSecretKeyToStorage(@NonNull final SecretKey unencryptedKey) thr span.end(); } } - final byte[] keyWrapped = AndroidKeyStoreUtil.wrap(unencryptedKey, keyPair, WRAP_ALGORITHM); + final byte[] keyWrapped = AndroidKeyStoreUtil.wrap(unencryptedKey, keyPair, WRAP_ALGORITHM, null); FileUtil.writeDataToFile(keyWrapped, getKeyFile()); } @@ -427,7 +438,7 @@ private void recordKeyGenerationTime(long keypairGenStartTime) { public void deleteSecretKeyFromStorage() throws ClientException { AndroidKeyStoreUtil.deleteKey(mAlias); FileUtil.deleteFile(getKeyFile()); - mKeyCache.clear(); + sKeyCacheMap.remove(mFilePath); } /** @@ -480,4 +491,4 @@ private File getKeyFile() { public String getCipherTransformation() { return CIPHER_TRANSFORMATION; } -} +} \ No newline at end of file From 1ab72c9c137f168128d3509dd2be7f7afefb8ab5 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 11 Jul 2025 15:43:47 -0700 Subject: [PATCH 18/51] remove rsamanager --- .../crypto/AndroidWrappedKeyProviderTest.java | 6 +- .../crypto/AndroidKeyStoreRsaKekManager.kt | 216 ------- .../AndroidWrappedKeyProviderFactory.kt | 2 +- .../identity/common/crypto/IKekManager.kt | 62 -- .../crypto/NewAndroidWrappedKeyProvider.java | 494 --------------- .../crypto/NewAndroidWrappedKeyProvider.kt | 582 ++++++++++++++++++ .../crypto/OAEPAndroidWrappedKeyProvider.kt | 217 ------- 7 files changed, 586 insertions(+), 993 deletions(-) delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/OAEPAndroidWrappedKeyProvider.kt diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index 46be2d5597..33cef72270 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -126,7 +126,7 @@ private AlgorithmParameterSpec getMockKeyPairGeneratorSpec(final String alias) { @Test public void testGenerateKey() throws ClientException { final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - final SecretKey secretKey = keyProvider.generateRandomKey(); + final SecretKey secretKey = keyProvider.generateNewSecretKey(); Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); } @@ -169,7 +169,7 @@ public void testLoadKey() throws ClientException { public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientException { // Create a new Keystore-wrapped key. final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - keyProvider.generateRandomKey(); + keyProvider.generateNewSecretKey(); final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); @@ -196,7 +196,7 @@ public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientExce public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException { // Create a new Keystore-wrapped key. final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - keyProvider.generateRandomKey(); + keyProvider.generateNewSecretKey(); final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt deleted file mode 100644 index 72eba0fe91..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidKeyStoreRsaKekManager.kt +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto - -import android.content.Context -import android.util.Log -import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil -import com.microsoft.identity.common.java.controllers.ExceptionAdapter -import com.microsoft.identity.common.java.exception.ClientException -import com.microsoft.identity.common.java.opentelemetry.AttributeName -import com.microsoft.identity.common.java.opentelemetry.OTelUtility -import com.microsoft.identity.common.java.opentelemetry.SpanExtension -import com.microsoft.identity.common.java.opentelemetry.SpanName -import com.microsoft.identity.common.logging.Logger -import io.opentelemetry.api.trace.StatusCode -import java.security.KeyPair -import javax.crypto.SecretKey - - -/** - * Implementation of [IKekManager] that uses Android KeyStore system with RSA key pairs - * for key encryption key (KEK) management. - * - * - * This class is responsible for generating RSA key pairs used for wrapping (encrypting) and - * unwrapping (decrypting) secret keys used in the application. It leverages the Android KeyStore - * system for secure key storage and handling. - * - * - * The implementation provides robustness through a fallback mechanism that tries multiple - * cipher and key generation specifications in order of preference, allowing for compatibility - * across different Android API levels and device capabilities. - */ -class AndroidKeyStoreRsaKekManager @JvmOverloads constructor( - private val keyAlias: String, - context: Context, - private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = - CryptoParameterSpecFactory(context, keyAlias) -) : IKekManager { - - companion object { - private val TAG: String = AndroidKeyStoreRsaKekManager::class.java.simpleName - - } - - /** - * Unwraps (decrypts) a previously wrapped secret key using the RSA private key - * from the Android KeyStore. - * - * - * This method tries multiple cipher specifications in order of preference to provide - * maximum compatibility across different Android API versions and devices. If the primary - * specification fails, it will attempt fallback specifications. - * - * @param wrappedSecretKey The wrapped (encrypted) key as a byte array - * @param secretKeyAlgorithm The algorithm of the secret key (e.g., "AES") - * @return The unwrapped plaintext SecretKey - * @throws ClientException if the key cannot be unwrapped due to missing key pair, - * invalid wrapped key data, or unsupported cipher specifications - */ - @Throws(ClientException::class) - override fun unwrapKey(keyPair: KeyPair, wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey { - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - Log.i(TAG, "Unwrapping key with CipherSpec: $cipherParamsSpec") - return AndroidKeyStoreUtil.unwrap( - wrappedSecretKey, - secretKeyAlgorithm, - keyPair, - cipherParamsSpec.transformation, - cipherParamsSpec.algorithmParameterSpec - ) - } - - - /** - * Wraps (encrypts) a secret key using the RSA public key from the Android KeyStore. - * - * - * If no key pair exists for the specified alias, this method will automatically - * generate a new key pair before performing the wrapping operation. - * - * @param keyToWrap The plaintext secret key that needs to be wrapped - * @return The wrapped (encrypted) key as a byte array - * @throws ClientException if key generation fails or if wrapping operation fails - */ - @Throws(ClientException::class) - override fun wrapKey(keyToWrap: SecretKey): ByteArray { - val methodTag = "$TAG:wrapKey" - var keyPair = AndroidKeyStoreUtil.readKey(keyAlias) - if (keyPair == null) { - Logger.info(methodTag, "No existing keypair found for alias. Generating a new keypair.") - keyPair = generateKeyPair() - } - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - Log.i(TAG, "Wrapping key with cipher: $cipherParamsSpec") - return AndroidKeyStoreUtil.wrap( - keyToWrap, - keyPair, - cipherParamsSpec.transformation, - cipherParamsSpec.algorithmParameterSpec - ) - } - - /** - * Selects the most appropriate [CipherSpec] for the given [KeyPair] by matching the supported - * encryption paddings from the Android Keystore with a prioritized list of available cipher specs. - * - * This function attempts to find a compatible cipher configuration for key wrapping by: - * 1. Fetching the encryption paddings supported by the provided [keyPair]. - * 2. Iterating through the prioritized list of [CipherSpec]s. - * 3. Returning the first compatible spec where the padding is supported by the key. - * - * If no matching specification is found, a fallback using PKCS#1 padding is returned. - * - * @param keyPair The [KeyPair] for which a compatible [CipherSpec] should be determined. - * @return A compatible [CipherSpec], or a fallback to a PKCS#1-based spec if none are supported. - */ - private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { - val methodTag = "$TAG:selectCompatibleCipherSpec" - val supportedPaddings = AndroidKeyStoreUtil.getEncryptionPaddings(keyPair) - val availableSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() - Log.i( - TAG, - "Supported paddings by the keyPair: $supportedPaddings" + - ",Specs available in order of priority: $availableSpecs" - ) - for (spec in availableSpecs) { - for (padding in supportedPaddings) { - if (spec.padding.contains(padding, ignoreCase = true)) { - return spec - } - } - } - Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") - // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. - return cryptoParameterSpecFactory.getPkcs1CipherSpec() - } - - /** - * Generates a new RSA key pair and stores it in the Android KeyStore. - * - * - * This method attempts to generate a key pair using multiple key generation specifications - * in order of preference. If the primary specification fails, it will attempt fallback - * specifications to ensure compatibility across different Android versions and devices. - * - * - * The key generation process is traced using OpenTelemetry for monitoring and diagnostics. - * - * @return A new RSA KeyPair stored in the Android KeyStore - * @throws ClientException if all key generation attempts fail - */ - @Throws(ClientException::class) - private fun generateKeyPair(): KeyPair { - val methodTag = "$TAG:generateKeyPair" - val span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name, SpanExtension.current().spanContext) - val failures = mutableListOf() - val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() - - try { - SpanExtension.makeCurrentSpan(span).use { _ -> - for (spec in specs) { - try { - val keypairGenStartTime = System.currentTimeMillis() - val keyPair = AndroidKeyStoreUtil.generateKeyPair( - spec.algorithm, - spec.algorithmParameterSpec - ) - val elapsedTime = System.currentTimeMillis() - keypairGenStartTime - SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) - span.setStatus(StatusCode.OK) - Log.i(TAG, "Key pair generated successfully with spec: $spec ") - return keyPair - } catch (throwable: Throwable) { - Logger.warn(methodTag, "Failed to generate key pair with spec: $spec") - failures.add(throwable) - } - } - - // If we reach here, all attempts have failed - failures.forEach { exception -> - Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) - } - val finalError = failures.lastOrNull() ?: ClientException( - ClientException.UNKNOWN_CRYPTO_ERROR, - "Key pair generation failed after trying all available specs." - ) - span.setStatus(StatusCode.ERROR) - span.recordException(finalError) - throw ExceptionAdapter.clientExceptionFromException(finalError) - } - } finally { - span.end() - } - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt index bc22b87a8b..be116f7135 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt @@ -59,7 +59,7 @@ object AndroidWrappedKeyProviderFactory { .isFlightEnabled(CommonFlight.ENABLE_OAEP_ANDROID_WRAPPED_KEY_LOADER) return if (enableOaepAndroidWrappedKeyLoader) { - OAEPAndroidWrappedKeyProvider( + NewAndroidWrappedKeyProvider( keyIdentifier, fileName, context diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt deleted file mode 100644 index 361dede2d9..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/IKekManager.kt +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto - -import com.microsoft.identity.common.java.exception.ClientException -import java.security.KeyPair -import javax.crypto.SecretKey - -/** - * Interface for key encryption key (KEK) generation and management. - * Defines the contract for generating and managing key pairs used to wrap/unwrap secret keys. - * Implementations can handle different algorithms and API versions. - */ -interface IKekManager { - - /** - * Wraps (encrypts) a secret key using the Key Encryption Key managed by this interface. - * The wrapped key can be safely stored and later unwrapped using the [.unwrapKey] method. - * - * @param keyToWrap The plaintext secret key that needs to be wrapped - * @return The wrapped (encrypted) key as a byte array - * @throws ClientException If wrapping fails due to cryptographic errors, key unavailability, - * or insufficient permissions - */ - @Throws(ClientException::class) - fun wrapKey(keyToWrap: SecretKey): ByteArray - - /** - * Unwraps (decrypts) a previously wrapped secret key using the Key Encryption Key. - * - * @param wrappedSecretKey The wrapped (encrypted) key as a byte array - * @param secretKeyAlgorithm The algorithm name of the wrapped secret key (e.g., "AES") - * needed to properly reconstruct the key after unwrapping - * @return The unwrapped plaintext secret key - * @throws ClientException If unwrapping fails due to cryptographic errors, - * key unavailability, tampered wrapped key, - * or insufficient permissions - */ - @Throws(ClientException::class) - fun unwrapKey(keyPair: KeyPair, wrappedSecretKey: ByteArray, secretKeyAlgorithm: String): SecretKey - -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java deleted file mode 100644 index 175fac3501..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.java +++ /dev/null @@ -1,494 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto; - -import static com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator.AES_ALGORITHM; - -import android.content.Context; -import android.os.Build; -import android.security.KeyPairGeneratorSpec; -import android.security.keystore.KeyGenParameterSpec; -import android.security.keystore.KeyProperties; - -import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; - -import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; -import com.microsoft.identity.common.java.controllers.ExceptionAdapter; -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.KeyUtil; -import com.microsoft.identity.common.java.exception.ClientException; -import com.microsoft.identity.common.java.flighting.CommonFlight; -import com.microsoft.identity.common.java.flighting.CommonFlightsManager; -import com.microsoft.identity.common.java.opentelemetry.AttributeName; -import com.microsoft.identity.common.java.opentelemetry.OTelUtility; -import com.microsoft.identity.common.java.opentelemetry.SpanExtension; -import com.microsoft.identity.common.java.opentelemetry.SpanName; -import com.microsoft.identity.common.java.util.FileUtil; -import com.microsoft.identity.common.java.util.StringUtil; -import com.microsoft.identity.common.logging.Logger; - -import java.io.File; -import java.math.BigInteger; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.KeyStore; -import java.security.spec.AlgorithmParameterSpec; -import java.util.Calendar; -import java.util.Locale; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -import javax.crypto.SecretKey; -import javax.security.auth.x500.X500Principal; - -import edu.umd.cs.findbugs.annotations.Nullable; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import io.opentelemetry.api.trace.Span; -import io.opentelemetry.api.trace.StatusCode; -import io.opentelemetry.context.Scope; -import lombok.NonNull; - -/** - * This class doesn't really use the KeyStore-generated key directly. - *

- * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key - * before it get saved to the file. - */ -public class NewAndroidWrappedKeyProvider implements ISecretKeyProvider { - - /** - * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in - * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here - * probably doing PKCS7. We decide to go with Java default string. - */ - private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"; - - private static final String TAG = NewAndroidWrappedKeyProvider.class.getSimpleName() + "#"; - - /** - * Should KeyStore and key file check for validity before every key load be skipped. - */ - @SuppressFBWarnings("MS_SHOULD_BE_FINAL") - public static boolean sSkipKeyInvalidationCheck = false; - - /** - * Algorithm for key wrapping. - */ - private static final String WRAP_ALGORITHM = "RSA/ECB/PKCS1Padding"; - - /** - * Algorithm for the wrapping key itself. - */ - private static final String WRAP_KEY_ALGORITHM = "RSA"; - - /** - * Indicate that token item is encrypted with the key loaded in this class. - */ - public static final String WRAPPED_KEY_KEY_IDENTIFIER = "A001"; - - // Exposed for testing only. - /* package */ static final int KEY_FILE_SIZE = 1024; - - /** - * SecretKey cache. Maps wrapped secret key file path to the SecretKey. - */ - private static final ConcurrentMap sKeyCacheMap = new ConcurrentHashMap<>(); - private final Context mContext; - - /** - * Name of the key itself. Must be unique. - */ - private final String mAlias; - - /** - * Name of the file contains the wrapped symmetric key used for encryption/decryption. - * Must be unique. - */ - private final String mFilePath; - - // Exposed for testing only. - @Nullable - @VisibleForTesting - /* package */ SecretKey getKeyFromCache() { - return sKeyCacheMap.get(mFilePath); - } - - // Exposed for testing only. - @VisibleForTesting - /* package */ void clearKeyFromCache() { - sKeyCacheMap.remove(mFilePath); - } - - /** - * Default constructor - * - * @param alias Alias(name) of the wrapping key. - * @param filePath Path to the file for storing the wrapped key. - * @param context Android's {@link Context} - */ - public NewAndroidWrappedKeyProvider(@NonNull final String alias, - @NonNull final String filePath, - @NonNull final Context context) { - mAlias = alias; - mFilePath = filePath; - mContext = context; - } - - @Override - @NonNull - public String getAlias() { - return mAlias; - } - - @Override - @NonNull - public String getKeyTypeIdentifier() { - return WRAPPED_KEY_KEY_IDENTIFIER; - } - - /** - * If key is already generated, that one will be returned. - * Otherwise, generate a new one and return. - */ - @Override - @NonNull - public synchronized SecretKey getKey() throws ClientException { - final String methodTag = TAG + ":getKey"; - if (!sSkipKeyInvalidationCheck && - (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !this.getKeyFile().exists())) { - sKeyCacheMap.remove(mFilePath); - } - - SecretKey key = sKeyCacheMap.get(mFilePath); - if (key != null) { - return key; - } - - Logger.info(methodTag, "Key not in cache or cache is empty, loading key from storage"); - key = readSecretKeyFromStorage(); - - // If key doesn't exist, generate a new one. - if (key == null) { - Logger.info(methodTag, "Key does not exist in storage, generating a new key"); - key = generateRandomKey(); - } - - sKeyCacheMap.put(mFilePath, key); - return key; - } - - @NonNull - protected SecretKey generateRandomKey() throws ClientException { - final String methodTag = TAG + ":generateRandomKey"; - - final SecretKey key = AES256SecretKeyGenerator.INSTANCE.generateRandomKey(); - saveSecretKeyToStorage(key); - - Logger.info(methodTag, "New key is generated with thumbprint: " + - KeyUtil.getKeyThumbPrint(key)); - - return key; - } - - /** - * Load the saved keystore-encrypted key. Will only do read operation. - * - * @return SecretKey. Null if there isn't any. - */ - @Nullable - /* package */ synchronized SecretKey readSecretKeyFromStorage() throws ClientException { - final String methodTag = TAG + ":readSecretKeyFromStorage"; - try { - final KeyPair keyPair = AndroidKeyStoreUtil.readKey(mAlias); - if (keyPair == null) { - Logger.info(methodTag, "key does not exist in keystore"); - deleteSecretKeyFromStorage(); - return null; - } - - final byte[] wrappedSecretKey = FileUtil.readFromFile(getKeyFile(), KEY_FILE_SIZE); - if (wrappedSecretKey == null) { - Logger.warn(methodTag, "Key file is empty"); - // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache - // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. - FileUtil.deleteFile(getKeyFile()); - sKeyCacheMap.remove(mFilePath); - return null; - } - - final SecretKey key = AndroidKeyStoreUtil.unwrap(wrappedSecretKey, AES_ALGORITHM, keyPair, WRAP_ALGORITHM, null); - - Logger.info(methodTag, "Key is loaded with thumbprint: " + - KeyUtil.getKeyThumbPrint(key)); - - return key; - } catch (final ClientException e) { - // Reset KeyPair info so that new request will generate correct KeyPairs. - // All tokens with previous SecretKey are not possible to decrypt. - Logger.warn(methodTag, "Error when loading key from Storage, " + - "wipe all existing key data "); - deleteSecretKeyFromStorage(); - throw e; - } - } - - /** - * Encrypt the given unencrypted symmetric key with Keystore key and save to storage. - */ - private void saveSecretKeyToStorage(@NonNull final SecretKey unencryptedKey) throws ClientException { - final String methodTag = TAG + ":saveSecretKeyToStorage"; - /* - * !!WARNING!! - * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring - * the sharedUserId attribute in their Android Manifest file. If multiple apps reference - * the same value for sharedUserId and are signed with the same keys, they will use - * the same AndroidKeyStore and may obtain access to the files and shared preferences - * of other applications by invoking createPackageContext. - * - * Support for sharedUserId is deprecated, however some applications still use this Android capability. - * See: https://developer.android.com/guide/topics/manifest/manifest-element - * - * To address apps in this scenario we will attempt to load an existing KeyPair - * instead of immediately generating a new key pair. This will use the same keypair - * to encrypt the symmetric key generated separately for each - * application using a shared linux user id... and avoid these applications from - * stomping/overwriting one another's keypair. - */ - KeyPair keyPair = AndroidKeyStoreUtil.readKey(mAlias); - if (keyPair == null) { - Logger.info(methodTag, "No existing keypair. Generating a new one."); - final Span span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name(), SpanExtension.current().getSpanContext()); - try (final Scope ignored = SpanExtension.makeCurrentSpan(span)) { - keyPair = generateNewKeyPair(); - span.setStatus(StatusCode.OK); - } catch (final ClientException e) { - span.setStatus(StatusCode.ERROR); - span.recordException(e); - throw e; - } finally { - span.end(); - } - } - final byte[] keyWrapped = AndroidKeyStoreUtil.wrap(unencryptedKey, keyPair, WRAP_ALGORITHM, null); - FileUtil.writeDataToFile(keyWrapped, getKeyFile()); - } - - /** - * Generate a new key pair wrapping key, based on API level uses different spec to generate - * the key pair. - * @return a key pair - */ - @NonNull - private KeyPair generateNewKeyPair() throws ClientException { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - return generateNewKeyPairAPI28AndAbove(); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return generateNewKeyPairAPI23AndAbove(); - } else { - return generateKeyPairWithLegacySpec(); - } - } - - /** - * Call this for API level >= 28. Starting level API 28 PURPOSE_WRAP_KEY is added. Based on flights - * this method may or may not use the PURPOSE_WRAP_KEY along with PURPOSE_ENCRYPT and PURPOSE_DECRYPT. The logic - * if (wrap key flight enabled) use all three purposes - * else if (new key gen flight enabled) use only encrypt and decrypt purposes - * else use legacy spec. - * @return key pair - */ - @RequiresApi(Build.VERSION_CODES.P) - @NonNull - private KeyPair generateNewKeyPairAPI28AndAbove() throws ClientException { - if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) { - return generateWrappingKeyPair_WithPurposeWrapKey(); - } else if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) { - return generateWrappingKeyPair(); - } else { - return generateKeyPairWithLegacySpec(); - } - } - - /** - * Call this for API level >= 23. Based on flight new key gen spec is used else legacy which - * is deprecated starting API 23. - * @return key pair - */ - @RequiresApi(Build.VERSION_CODES.M) - @NonNull - private KeyPair generateNewKeyPairAPI23AndAbove() throws ClientException { - if (CommonFlightsManager.INSTANCE.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) { - return generateWrappingKeyPair(); - } else { - return generateKeyPairWithLegacySpec(); - } - } - - /** - * Generate a new key pair wrapping key based on legacy logic. Call this for API < 23 or as fallback - * until new key gen specs are stable. - * @return key pair generated with legacy spec - * @throws ClientException if there is an error generating the key pair. - */ - @NonNull - private KeyPair generateKeyPairWithLegacySpec() throws ClientException{ - final Span span = SpanExtension.current(); - try { - final AlgorithmParameterSpec keyPairGenSpec = getLegacySpecForKeyStoreKey(); - final KeyPair keyPair = attemptKeyPairGeneration(keyPairGenSpec); - span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), "legacy_key_gen_spec"); - return keyPair; - } catch (final Throwable e) { - Logger.error(TAG + ":generateKeyPairWithLegacySpec", "Error generating keypair with legacy spec.", e); - throw ExceptionAdapter.clientExceptionFromException(e); - } - } - - /** - * Generate a new key pair wrapping key, based on API level >= 28. This method uses new key gen spec - * with PURPOSE_WRAP_KEY. If this fails, it will fallback to generateWrappingKeyPair() which does not use - * PURPOSE_WRAP_KEY (still uses new key gen spec). - */ - @RequiresApi(Build.VERSION_CODES.P) - private KeyPair generateWrappingKeyPair_WithPurposeWrapKey() throws ClientException { - final String methodTag = TAG + ":generateWrappingKeyPair_WithPurposeWrapKey"; - final Span span = SpanExtension.current(); - try { - Logger.info(methodTag, "Generating new keypair with new spec with purpose_wrap_key"); - int purposes = KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_WRAP_KEY; - final AlgorithmParameterSpec keyPairGenSpec = getSpecForWrappingKey(purposes); - final KeyPair keyPair = attemptKeyPairGeneration(keyPairGenSpec); - span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), "new_key_gen_spec_with_wrap"); - return keyPair; - } catch (final Throwable e) { - Logger.error(methodTag, "Error generating keypair with new spec with purpose_wrap_key." + - "Attempting without purpose_wrap_key." , e); - if (!StringUtil.isNullOrEmpty(e.getMessage())) { - span.setAttribute(AttributeName.keypair_gen_exception.name(), e.getMessage()); - } - return generateWrappingKeyPair(); - } - } - - /** - * Generate a new key pair wrapping key, based on API level >= 23. This method uses new key gen spec - * with purposes PURPOSE_ENCRYPT and PURPOSE_DECRYPT. If this fails, it will fallback to generateKeyPairWithLegacySpec() - * which uses olg key gen spec. - */ - @RequiresApi(Build.VERSION_CODES.M) - private KeyPair generateWrappingKeyPair() throws ClientException { - final String methodTag = TAG + ":generateWrappingKeyPair"; - final Span span = SpanExtension.current(); - try { - Logger.info(methodTag, "Generating new keypair with new spec without wrap key"); - int purposes = KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT; - final AlgorithmParameterSpec keyPairGenSpec = getSpecForWrappingKey(purposes); - final KeyPair keyPair = attemptKeyPairGeneration(keyPairGenSpec); - span.setAttribute(AttributeName.key_pair_gen_successful_method.name(), "new_key_gen_spec_without_wrap"); - return keyPair; - } catch (final Throwable e) { - Logger.error(methodTag, "Error generating keypair with new spec." + - "Attempting with legacy spec.", e); - if (!StringUtil.isNullOrEmpty(e.getMessage())) { - span.setAttribute(AttributeName.keypair_gen_exception.name(), e.getMessage()); - } - return generateKeyPairWithLegacySpec(); - } - } - - private KeyPair attemptKeyPairGeneration(@NonNull final AlgorithmParameterSpec keyPairGenSpec) throws ClientException{ - final long keypairGenStartTime = System.currentTimeMillis(); - final KeyPair keyPair = AndroidKeyStoreUtil.generateKeyPair( - WRAP_KEY_ALGORITHM, keyPairGenSpec); - recordKeyGenerationTime(keypairGenStartTime); - return keyPair; - } - - private void recordKeyGenerationTime(long keypairGenStartTime) { - long elapsedTime = System.currentTimeMillis() - keypairGenStartTime; - SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name(), elapsedTime); - } - - /** - * Wipe all the data associated from this key. - */ - // VisibleForTesting - public void deleteSecretKeyFromStorage() throws ClientException { - AndroidKeyStoreUtil.deleteKey(mAlias); - FileUtil.deleteFile(getKeyFile()); - sKeyCacheMap.remove(mFilePath); - } - - /** - * Generate a self-signed cert and derive an AlgorithmParameterSpec from that. - * This is for the key to be generated in {@link KeyStore} via {@link KeyPairGenerator} - * Note : This is now only for API level < 23 or as fallback. - - * @return a {@link AlgorithmParameterSpec} for the keystore key (that we'll use to wrap the secret key). - */ - private AlgorithmParameterSpec getLegacySpecForKeyStoreKey() { - // Generate a self-signed cert. - final String certInfo = String.format(Locale.ROOT, "CN=%s, OU=%s", - mAlias, - mContext.getPackageName()); - - final Calendar start = Calendar.getInstance(); - final Calendar end = Calendar.getInstance(); - final int certValidYears = 100; - end.add(Calendar.YEAR, certValidYears); - - return new KeyPairGeneratorSpec.Builder(mContext) - .setAlias(mAlias) - .setSubject(new X500Principal(certInfo)) - .setSerialNumber(BigInteger.ONE) - .setStartDate(start.getTime()) - .setEndDate(end.getTime()) - .build(); - } - - @RequiresApi(api = Build.VERSION_CODES.M) - private AlgorithmParameterSpec getSpecForWrappingKey(int purposes) { - return new KeyGenParameterSpec.Builder(mAlias, purposes) - .setKeySize(2048) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) - .build(); - } - - /** - * Get the file that stores the wrapped key. - */ - private File getKeyFile() { - return new File( - mContext.getDir(mContext.getPackageName(), Context.MODE_PRIVATE), - mFilePath); - } - - @NonNull - @Override - public String getCipherTransformation() { - return CIPHER_TRANSFORMATION; - } -} \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt new file mode 100644 index 0000000000..91fb61d80a --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt @@ -0,0 +1,582 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import android.content.Context +import android.os.Build +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil +import com.microsoft.identity.common.java.controllers.ExceptionAdapter +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.KeyUtil +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.flighting.CommonFlight +import com.microsoft.identity.common.java.flighting.CommonFlightsManager.getFlightsProvider +import com.microsoft.identity.common.java.opentelemetry.AttributeName +import com.microsoft.identity.common.java.opentelemetry.OTelUtility +import com.microsoft.identity.common.java.opentelemetry.SpanExtension +import com.microsoft.identity.common.java.opentelemetry.SpanName +import com.microsoft.identity.common.java.util.FileUtil +import com.microsoft.identity.common.java.util.StringUtil +import com.microsoft.identity.common.logging.Logger +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings +import io.opentelemetry.api.trace.StatusCode +import java.io.File +import java.math.BigInteger +import java.security.KeyPair +import java.security.spec.AlgorithmParameterSpec +import java.util.Calendar +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import javax.crypto.SecretKey +import javax.security.auth.x500.X500Principal + +/** + * This class doesn't really use the KeyStore-generated key directly. + * + * + * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key + * before it get saved to the file. + */ +class NewAndroidWrappedKeyProvider( + override val alias: String, + private val mFilePath: String, + private val mContext: Context +) : ISecretKeyProvider { + override val keyTypeIdentifier = KEY_TYPE_IDENTIFIER + override val cipherTransformation = AES_CBC_PKCS5_PADDING_TRANSFORMATION + private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = + CryptoParameterSpecFactory(mContext, alias) + @get:VisibleForTesting + val keyFromCache: SecretKey? + get() { + clearCachedKeyIfCantLoadOrFileDoesNotExist() + return sKeyCacheMap[mFilePath] + } + + + @VisibleForTesting + fun clearKeyFromCache() { + sKeyCacheMap.remove(mFilePath) + } + + private fun clearCachedKeyIfCantLoadOrFileDoesNotExist() { + val shouldClearCache = !sSkipKeyInvalidationCheck && + (!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists()) + if (shouldClearCache) { + sKeyCacheMap.remove(mFilePath) + } + } + + + @get:Throws(ClientException::class) + @get:Synchronized + override val key: SecretKey + /** + * If key is already generated, that one will be returned. + * Otherwise, generate a new one and return. + */ + get() { + val methodTag = "$TAG:getKey" + + keyFromCache?.let { + Logger.info( + methodTag, + "Key is already cached, returning cached key with thumbprint: " + + KeyUtil.getKeyThumbPrint(it) + ) + return it + } + + readSecretKeyFromStorage()?.let { + sKeyCacheMap[mFilePath] = it + Logger.info( + methodTag, + "Key loaded from storage and cached with thumbprint: " + + KeyUtil.getKeyThumbPrint(it) + ) + return it + } + + val newKey = generateNewSecretKey() + sKeyCacheMap[mFilePath] = newKey + Logger.info( + methodTag, + "New key is generated and cached with thumbprint: " + + KeyUtil.getKeyThumbPrint(newKey) + ) + return newKey + } + + @Throws(ClientException::class) + fun generateNewSecretKey(): SecretKey { + /* + * !!WARNING!! + * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring + * the sharedUserId attribute in their Android Manifest file. If multiple apps reference + * the same value for sharedUserId and are signed with the same keys, they will use + * the same AndroidKeyStore and may obtain access to the files and shared preferences + * of other applications by invoking createPackageContext. + * + * Support for sharedUserId is deprecated, however some applications still use this Android capability. + * See: https://developer.android.com/guide/topics/manifest/manifest-element + * + * To address apps in this scenario we will attempt to load an existing KeyPair + * instead of immediately generating a new key pair. This will use the same keypair + * to encrypt the symmetric key generated separately for each + * application using a shared linux user id... and avoid these applications from + * stomping/overwriting one another's keypair. + */ + val methodTag = "$TAG:generateRandomKey" + val newSecretKey = AES256SecretKeyGenerator.generateRandomKey() + val keyPair : KeyPair = AndroidKeyStoreUtil.readKey(alias) + ?: run { + Logger.info(methodTag, "No existing keypair found. Generating a new one.") + generateKeyPair() + //generateNewKeyPair() + } + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + Log.i( + methodTag, + "Selected cipher spec for key wrapping: ${cipherParamsSpec.transformation}"+ + "\n cipherParamsSpec = ${cipherParamsSpec.algorithmParameterSpec}" + ) + val keyWrapped = AndroidKeyStoreUtil.wrap( + newSecretKey, + keyPair, + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec + ) + FileUtil.writeDataToFile(keyWrapped, keyFile) + return newSecretKey + } + + /** + * Load the saved keystore-encrypted key. Will only do read operation. + * + * @return SecretKey. Null if there isn't any. + */ + /* package */@Synchronized + @Throws(ClientException::class) + fun readSecretKeyFromStorage(): SecretKey? { + val methodTag = "$TAG:readSecretKeyFromStorage" + try { + val keyPair = AndroidKeyStoreUtil.readKey(alias) + if (keyPair == null) { + Logger.info(methodTag, "key does not exist in keystore") + deleteSecretKeyFromStorage() + return null + } + + val wrappedSecretKey = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) + if (wrappedSecretKey == null) { + Logger.warn(methodTag, "Key file is empty") + // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache + // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. + FileUtil.deleteFile(keyFile) + clearKeyFromCache() + return null + } + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + Log.i( + methodTag, + "Selected cipher spec for key unwrapping: ${cipherParamsSpec.transformation}"+ + "\n cipherParamsSpec = ${cipherParamsSpec.algorithmParameterSpec}" + ) + val key = AndroidKeyStoreUtil.unwrap( + wrappedSecretKey, + AES256SecretKeyGenerator.AES_ALGORITHM, + keyPair, + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec + ) + + Logger.info( + methodTag, "Key is loaded with thumbprint: " + + KeyUtil.getKeyThumbPrint(key) + ) + + return key + } catch (e: ClientException) { + // Reset KeyPair info so that new request will generate correct KeyPairs. + // All tokens with previous SecretKey are not possible to decrypt. + Logger.warn( + methodTag, "Error when loading key from Storage, " + + "wipe all existing key data " + ) + deleteSecretKeyFromStorage() + throw e + } + } + + + /** + * Generate a new key pair wrapping key, based on API level uses different spec to generate + * the key pair. + * @return a key pair + */ + @Throws(ClientException::class) + private fun generateNewKeyPair(): KeyPair { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + generateNewKeyPairAPI28AndAbove() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + generateNewKeyPairAPI23AndAbove() + } else { + generateKeyPairWithLegacySpec() + } + } + + /** + * Call this for API level >= 28. Starting level API 28 PURPOSE_WRAP_KEY is added. Based on flights + * this method may or may not use the PURPOSE_WRAP_KEY along with PURPOSE_ENCRYPT and PURPOSE_DECRYPT. The logic + * if (wrap key flight enabled) use all three purposes + * else if (new key gen flight enabled) use only encrypt and decrypt purposes + * else use legacy spec. + * @return key pair + */ + @RequiresApi(Build.VERSION_CODES.P) + @Throws( + ClientException::class + ) + private fun generateNewKeyPairAPI28AndAbove(): KeyPair { + return if (getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) + ) { + generateWrappingKeyPair_WithPurposeWrapKey() + } else if (getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) + ) { + generateWrappingKeyPair() + } else { + generateKeyPairWithLegacySpec() + } + } + + /** + * Call this for API level >= 23. Based on flight new key gen spec is used else legacy which + * is deprecated starting API 23. + * @return key pair + */ + @RequiresApi(Build.VERSION_CODES.M) + @Throws( + ClientException::class + ) + private fun generateNewKeyPairAPI23AndAbove(): KeyPair { + return if (getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) + ) { + generateWrappingKeyPair() + } else { + generateKeyPairWithLegacySpec() + } + } + + /** + * Generate a new key pair wrapping key based on legacy logic. Call this for API < 23 or as fallback + * until new key gen specs are stable. + * @return key pair generated with legacy spec + * @throws ClientException if there is an error generating the key pair. + */ + @Throws(ClientException::class) + private fun generateKeyPairWithLegacySpec(): KeyPair { + val span = SpanExtension.current() + try { + val keyPairGenSpec = + legacySpecForKeyStoreKey + val keyPair = attemptKeyPairGeneration(keyPairGenSpec) + span.setAttribute( + AttributeName.key_pair_gen_successful_method.name, + "legacy_key_gen_spec" + ) + return keyPair + } catch (e: Throwable) { + Logger.error( + TAG + ":generateKeyPairWithLegacySpec", + "Error generating keypair with legacy spec.", + e + ) + throw ExceptionAdapter.clientExceptionFromException(e) + } + } + + /** + * Generate a new key pair wrapping key, based on API level >= 28. This method uses new key gen spec + * with PURPOSE_WRAP_KEY. If this fails, it will fallback to generateWrappingKeyPair() which does not use + * PURPOSE_WRAP_KEY (still uses new key gen spec). + */ + @RequiresApi(Build.VERSION_CODES.P) + @Throws( + ClientException::class + ) + private fun generateWrappingKeyPair_WithPurposeWrapKey(): KeyPair { + val methodTag = TAG + ":generateWrappingKeyPair_WithPurposeWrapKey" + val span = SpanExtension.current() + try { + Logger.info(methodTag, "Generating new keypair with new spec with purpose_wrap_key") + val purposes = + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_WRAP_KEY + val keyPairGenSpec = getSpecForWrappingKey(purposes) + val keyPair = attemptKeyPairGeneration(keyPairGenSpec) + span.setAttribute( + AttributeName.key_pair_gen_successful_method.name, + "new_key_gen_spec_with_wrap" + ) + return keyPair + } catch (e: Throwable) { + Logger.error( + methodTag, "Error generating keypair with new spec with purpose_wrap_key." + + "Attempting without purpose_wrap_key.", e + ) + if (!StringUtil.isNullOrEmpty(e.message)) { + span.setAttribute(AttributeName.keypair_gen_exception.name, e.message) + } + return generateWrappingKeyPair() + } + } + + /** + * Generate a new key pair wrapping key, based on API level >= 23. This method uses new key gen spec + * with purposes PURPOSE_ENCRYPT and PURPOSE_DECRYPT. If this fails, it will fallback to generateKeyPairWithLegacySpec() + * which uses olg key gen spec. + */ + @RequiresApi(Build.VERSION_CODES.M) + @Throws( + ClientException::class + ) + private fun generateWrappingKeyPair(): KeyPair { + val methodTag = TAG + ":generateWrappingKeyPair" + val span = SpanExtension.current() + try { + Logger.info(methodTag, "Generating new keypair with new spec without wrap key") + val purposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + val keyPairGenSpec = getSpecForWrappingKey(purposes) + val keyPair = attemptKeyPairGeneration(keyPairGenSpec) + span.setAttribute( + AttributeName.key_pair_gen_successful_method.name, + "new_key_gen_spec_without_wrap" + ) + return keyPair + } catch (e: Throwable) { + Logger.error( + methodTag, "Error generating keypair with new spec." + + "Attempting with legacy spec.", e + ) + if (!StringUtil.isNullOrEmpty(e.message)) { + span.setAttribute(AttributeName.keypair_gen_exception.name, e.message) + } + return generateKeyPairWithLegacySpec() + } + } + + @Throws(ClientException::class) + private fun attemptKeyPairGeneration(keyPairGenSpec: AlgorithmParameterSpec): KeyPair { + val keypairGenStartTime = System.currentTimeMillis() + val keyPair = AndroidKeyStoreUtil.generateKeyPair( + WRAP_KEY_ALGORITHM, keyPairGenSpec + ) + recordKeyGenerationTime(keypairGenStartTime) + return keyPair + } + + private fun recordKeyGenerationTime(keypairGenStartTime: Long) { + val elapsedTime = System.currentTimeMillis() - keypairGenStartTime + SpanExtension.current() + .setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) + } + + /** + * Wipe all the data associated from this key. + */ + // VisibleForTesting + @Throws(ClientException::class) + fun deleteSecretKeyFromStorage() { + AndroidKeyStoreUtil.deleteKey(alias) + FileUtil.deleteFile(keyFile) + sKeyCacheMap.remove(mFilePath) + } + + private val legacySpecForKeyStoreKey: AlgorithmParameterSpec + /** + * Generate a self-signed cert and derive an AlgorithmParameterSpec from that. + * This is for the key to be generated in [KeyStore] via [KeyPairGenerator] + * Note : This is now only for API level < 23 or as fallback. + * + * @return a [AlgorithmParameterSpec] for the keystore key (that we'll use to wrap the secret key). + */ + get() { + // Generate a self-signed cert. + val certInfo = String.format( + Locale.ROOT, "CN=%s, OU=%s", + alias, + mContext.packageName + ) + + val start = Calendar.getInstance() + val end = Calendar.getInstance() + val certValidYears = 100 + end.add(Calendar.YEAR, certValidYears) + + return KeyPairGeneratorSpec.Builder(mContext) + .setAlias(alias) + .setSubject(X500Principal(certInfo)) + .setSerialNumber(BigInteger.ONE) + .setStartDate(start.time) + .setEndDate(end.time) + .build() + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private fun getSpecForWrappingKey(purposes: Int): AlgorithmParameterSpec { + return KeyGenParameterSpec.Builder(alias, purposes) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .build() + } + + private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { + val methodTag = "$TAG:selectCompatibleCipherSpec" + val supportedPaddings = AndroidKeyStoreUtil.getEncryptionPaddings(keyPair) + val availableSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() + Logger.verbose( + methodTag, + "Supported paddings by the keyPair: $supportedPaddings" + + ",Specs available in order of priority: $availableSpecs" + ) + for (spec in availableSpecs) { + for (padding in supportedPaddings) { + if (spec.padding.contains(padding, ignoreCase = true)) { + return spec + } + } + } + Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") + // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. + return cryptoParameterSpecFactory.getPkcs1CipherSpec() + } + + + @Throws(ClientException::class) + private fun generateKeyPair(): KeyPair { + val methodTag = "$TAG:generateKeyPair" + val span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name, SpanExtension.current().spanContext) + val failures = mutableListOf() + val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() + + try { + SpanExtension.makeCurrentSpan(span).use { _ -> + for (spec in specs) { + try { + val keypairGenStartTime = System.currentTimeMillis() + val keyPair = AndroidKeyStoreUtil.generateKeyPair( + spec.algorithm, + spec.algorithmParameterSpec + ) + val elapsedTime = System.currentTimeMillis() - keypairGenStartTime + SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) + span.setStatus(StatusCode.OK) + Log.i(methodTag, "Key pair generated successfully with spec: $spec ") + return keyPair + } catch (throwable: Throwable) { + Logger.warn(methodTag, "Failed to generate key pair with spec: $spec") + failures.add(throwable) + } + } + + // If we reach here, all attempts have failed + failures.forEach { exception -> + Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) + } + val finalError = failures.lastOrNull() ?: ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "Key pair generation failed after trying all available specs." + ) + span.setStatus(StatusCode.ERROR) + span.recordException(finalError) + throw ExceptionAdapter.clientExceptionFromException(finalError) + } + } finally { + span.end() + } + } + + + private val keyFile: File + /** + * Get the file that stores the wrapped key. + */ + get() = File( + mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), + mFilePath + ) + + companion object { + /** + * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in + * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here + * probably doing PKCS7. We decide to go with Java default string. + */ + const val AES_CBC_PKCS5_PADDING_TRANSFORMATION: String = "AES/CBC/PKCS5Padding" + + + private val TAG = NewAndroidWrappedKeyProvider::class.java.simpleName + "#" + + /** + * Should KeyStore and key file check for validity before every key load be skipped. + */ + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + var sSkipKeyInvalidationCheck: Boolean = false + + /** + * Algorithm for key wrapping. + */ + private const val WRAP_ALGORITHM = "RSA/ECB/PKCS1Padding" + + /** + * Algorithm for the wrapping key itself. + */ + private const val WRAP_KEY_ALGORITHM = "RSA" + + /** + * Indicate that token item is encrypted with the key loaded in this class. + */ + const val KEY_TYPE_IDENTIFIER: String = "A001" + + + // Exposed for testing only. + /* package */ + const val KEY_FILE_SIZE: Int = 1024 + + /** + * SecretKey cache. Maps wrapped secret key file path to the SecretKey. + */ + private val sKeyCacheMap: ConcurrentMap = ConcurrentHashMap() + } +} \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/OAEPAndroidWrappedKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/OAEPAndroidWrappedKeyProvider.kt deleted file mode 100644 index ff749ee703..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/OAEPAndroidWrappedKeyProvider.kt +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto - -import android.content.Context -import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil -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.KeyUtil -import com.microsoft.identity.common.java.exception.ClientException -import com.microsoft.identity.common.java.util.CachedData -import com.microsoft.identity.common.java.util.FileUtil -import com.microsoft.identity.common.logging.Logger -import java.io.File -import javax.crypto.SecretKey - -/** - * This class doesn't really use the KeyStore-generated key directly. - * - * - * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key - * before it get saved to the file. - */ -class OAEPAndroidWrappedKeyProvider @JvmOverloads constructor( - override val alias: String, - private val mFilePath: String, - private val mContext: Context, - private val mKekManager: IKekManager = AndroidKeyStoreRsaKekManager(alias, mContext) -) : ISecretKeyProvider { - - - // Exposed for testing only. - val keyCache: CachedData = object : CachedData() { - override fun getData(): SecretKey? { - if (AndroidWrappedKeyProviderFactory.skipKeyInvalidationCheck) { - return super.getData() - } - if ((!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists())) { - this.clear() - } - return super.getData() - } - } - - - - /** - * If key is already generated, that one will be returned. - * Otherwise, generate a new one and return. - */ - @get:Throws(ClientException::class) - @get:Synchronized - override val key: SecretKey - get() { - val methodTag = "$TAG:key" - keyCache.data?.let { keyOnCache -> - Logger.info( - methodTag, "Key is loaded from cache with thumbprint: " + - KeyUtil.getKeyThumbPrint(keyOnCache) - ) - return keyOnCache - } - readSecretKeyFromStorage()?.let { keyFromStorage -> - Logger.info( - methodTag, "Key is loaded from storage with thumbprint: " + - KeyUtil.getKeyThumbPrint(keyFromStorage) - ) - keyCache.data = keyFromStorage - return keyFromStorage - } - - AES256SecretKeyGenerator.generateRandomKey().let { newKey -> - Logger.info( - methodTag, "New key is generated with thumbprint: " + - KeyUtil.getKeyThumbPrint(newKey) - ) - saveSecretKeyToStorage(newKey) - keyCache.data = newKey - return newKey - } - } - - - /** - * Load the saved keystore-encrypted key. Will only do read operation. - * - * @return SecretKey. Null if there isn't any. - */ - /* package */@Synchronized - @Throws(ClientException::class) - fun readSecretKeyFromStorage(): SecretKey? { - val methodTag = "$TAG:readSecretKeyFromStorage" - try { - // Check if the KeyPair exists in the AndroidKeyStore. - val keyPair = AndroidKeyStoreUtil.readKey(alias) ?: run { - Logger.info(methodTag, "key does not exist in keystore") - deleteSecretKeyFromStorage() - return null - } - - // Check if the key file exists. - val wrappedSecretKey = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) ?: run { - Logger.warn(methodTag, "Key file is empty") - // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache - // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. - FileUtil.deleteFile(keyFile) - FileUtil.deleteFile(keyAlgorithmFile) - keyCache.clear() - return null - } - - val key = mKekManager.unwrapKey(keyPair, wrappedSecretKey, "AES") - Logger.info( - methodTag, - "Key is loaded with thumbprint: " + KeyUtil.getKeyThumbPrint(key) - ) - return key - } catch (e: ClientException) { - // Reset KeyPair info so that new request will generate correct KeyPairs. - // All tokens with previous SecretKey are not possible to decrypt. - Logger.warn( - methodTag, "Error when loading key from Storage, " + - "wipe all existing key data " - ) - deleteSecretKeyFromStorage() - throw e - } - } - - /** - * Encrypt the given unencrypted symmetric key with Keystore key and save to storage. - */ - @Throws(ClientException::class) - private fun saveSecretKeyToStorage( - unencryptedKey: SecretKey) { - /* - * !!WARNING!! - * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring - * the sharedUserId attribute in their Android Manifest file. If multiple apps reference - * the same value for sharedUserId and are signed with the same keys, they will use - * the same AndroidKeyStore and may obtain access to the files and shared preferences - * of other applications by invoking createPackageContext. - * - * Support for sharedUserId is deprecated, however some applications still use this Android capability. - * See: https://developer.android.com/guide/topics/manifest/manifest-element - * - * To address apps in this scenario we will attempt to load an existing KeyPair - * instead of immediately generating a new key pair. This will use the same keypair - * to encrypt the symmetric key generated separately for each - * application using a shared linux user id... and avoid these applications from - * stomping/overwriting one another's keypair. - */ - val keyWrapped = mKekManager.wrapKey(unencryptedKey) - FileUtil.writeDataToFile(keyWrapped, keyFile) - } - - /** - * Wipe all the data associated from this key. - */ - // VisibleForTesting - @Throws(ClientException::class) - fun deleteSecretKeyFromStorage() { - AndroidKeyStoreUtil.deleteKey(alias) - FileUtil.deleteFile(keyFile) - FileUtil.deleteFile(keyAlgorithmFile) - keyCache.clear() - } - - private val keyFile: File - get() = File( - mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), - mFilePath - ) - - private val keyAlgorithmFile: File - get() = File( - mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), - SECRET_KEY_ALGORITHM_FILE - ) - - override val cipherTransformation: String - get() = "AES/CBC/PKCS5Padding" - - override val keyTypeIdentifier: String - get() = "WRAPPED_KEY_KEY_IDENTIFIER" - - companion object { - private val TAG = OAEPAndroidWrappedKeyProvider::class.java.simpleName - - // Exposed for testing only. - const val KEY_FILE_SIZE: Int = 1024 - - // Exposed for testing only. - const val SECRET_KEY_ALGORITHM_FILE: String = "key_algorithm_file" - } -} - From fb0fcf9f3ec7c1e9fff93cdb088e46d10a4671f0 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 11 Jul 2025 17:30:58 -0700 Subject: [PATCH 19/51] feat: Add CipherSpec and KeyGenSpec classes for cryptographic operations; refactor NewAndroidWrappedKeyProvider to utilize new specs and remove legacy key generation methods. --- .../crypto/{CryptoSpecs.kt => CipherSpec.kt} | 43 +-- .../crypto/CryptoParameterSpecFactory.kt | 136 +++---- .../identity/common/crypto/KeyGenSpec.kt | 133 +++++++ .../crypto/KeyStoreBackedSecretKeyProvider.kt | 346 ------------------ .../crypto/NewAndroidWrappedKeyProvider.kt | 239 +----------- 5 files changed, 186 insertions(+), 711 deletions(-) rename common/src/main/java/com/microsoft/identity/common/crypto/{CryptoSpecs.kt => CipherSpec.kt} (56%) create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt similarity index 56% rename from common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt index 59af269393..f8ff43097a 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoSpecs.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt @@ -25,17 +25,6 @@ package com.microsoft.identity.common.crypto import java.security.spec.AlgorithmParameterSpec -/** - * A base interface for cryptographic specifications. - * - * This interface provides a common structure for different types of cryptographic parameter - * specifications used throughout the application. It ensures that any specification class - * includes an [AlgorithmParameterSpec], which is a standard Java Security class for specifying - * algorithm parameters. - */ -interface CryptoSpec { - val algorithmParameterSpec: AlgorithmParameterSpec? -} /** * Data class to hold cipher parameter specifications for encryption and decryption operations. @@ -51,11 +40,11 @@ interface CryptoSpec { * @property padding The padding scheme used for the cipher (e.g., "PKCS1Padding", "OAEPwithSHA-256andMGF1Padding"). */ data class CipherSpec( - override val algorithmParameterSpec: AlgorithmParameterSpec?, - val algorithm: String, - val mode: String, + val algorithmParameterSpec: AlgorithmParameterSpec?, + private val algorithm: String, + private val mode: String, val padding: String, -) : CryptoSpec { +) { /** * The full transformation string (e.g., "RSA/ECB/PKCS1Padding") used to initialize a * [javax.crypto.Cipher] instance. @@ -67,27 +56,3 @@ data class CipherSpec( } } -/** - * Data class to hold parameter specifications for cryptographic key generation. - * - * This class encapsulates all the necessary information to generate a new cryptographic key pair, - * including a description for logging, the algorithm, the padding scheme to be associated with the key, - * and the detailed algorithm parameter specification. - * - * @property description A descriptive string for the specification, useful for logging and debugging. - * @property algorithm The key generation algorithm, typically "RSA". - * @property encryptionPadding The encryption padding scheme that the generated key will support - * (e.g., [android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1]). - * @property algorithmParameterSpec The detailed key generation parameter specification, such as - * [android.security.keystore.KeyGenParameterSpec] or [android.security.KeyPairGeneratorSpec]. - */ -data class KeyGenSpec( - val description: String, - val algorithm: String, - val encryptionPadding: String, - override val algorithmParameterSpec: AlgorithmParameterSpec, -) : CryptoSpec { - override fun toString(): String { - return "KeyGenSpec(description='$description', algorithm='$algorithm', encryptionPadding='$encryptionPadding')" - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index b28fe43572..0d3db87486 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -24,22 +24,15 @@ package com.microsoft.identity.common.crypto import android.content.Context import android.os.Build -import android.security.KeyPairGeneratorSpec -import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import androidx.annotation.RequiresApi import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager.getFlightsProvider import com.microsoft.identity.common.java.flighting.IFlightsProvider import com.microsoft.identity.common.logging.Logger -import java.math.BigInteger -import java.security.spec.AlgorithmParameterSpec import java.security.spec.MGF1ParameterSpec -import java.util.Calendar -import java.util.Locale import javax.crypto.spec.OAEPParameterSpec import javax.crypto.spec.PSource -import javax.security.auth.x500.X500Principal /** * A factory for creating cryptographic parameter specifications for key generation and cipher operations. @@ -69,8 +62,8 @@ import javax.security.auth.x500.X500Principal * This factory helps navigate these limitations by providing appropriate fallback mechanisms. */ class CryptoParameterSpecFactory( - private val context: Context, - private val keyAlias: String, + context: Context, + keyAlias: String, flightsProvider: IFlightsProvider = getFlightsProvider() ) { @@ -108,12 +101,12 @@ class CryptoParameterSpecFactory( flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) private val keySpecWithoutPurposeKey = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - private val supportsEncryptionPaddingRsaOaep = + private val supportsKeyGenEncryptionPaddingRsaOaep = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) // Cipher parameter specifications - private val pkcs1CipherSpec = CipherSpec( + val pkcs1CipherSpec = CipherSpec( algorithmParameterSpec = null, algorithm = RSA_ALGORITHM, mode = MODE_ECB, @@ -127,89 +120,50 @@ class CryptoParameterSpecFactory( padding = OAEP_PADDING_WITH_256MGF1, ) - // Key generation parameter specifications - @delegate:RequiresApi(Build.VERSION_CODES.P) - private val keyGenParamSpecWithPurposeWrapKey by lazy { + + @RequiresApi(Build.VERSION_CODES.P) + private val keyGenParamSpecWithPurposeWrapKey = KeyGenSpec( - algorithmParameterSpec = createKeyGenParameterSpec( - KeyProperties.PURPOSE_ENCRYPT or - KeyProperties.PURPOSE_DECRYPT or - KeyProperties.PURPOSE_WRAP_KEY + keyAlias = keyAlias, + purposes = KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT or + KeyProperties.PURPOSE_WRAP_KEY, + keySize = KEY_SIZE, + digestAlgorithms = listOf( + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA512 ), description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY, - encryptionPadding = getEncryptionPaddings(), + encryptionPaddings = getEncryptionPaddings(), algorithm = RSA_ALGORITHM ) - } - @delegate:RequiresApi(Build.VERSION_CODES.M) - private val keyGenParamSpecWithoutPurposeWrapKey by lazy { + @RequiresApi(Build.VERSION_CODES.M) + private val keyGenParamSpecWithoutPurposeWrapKey = KeyGenSpec( - algorithmParameterSpec = createKeyGenParameterSpec( - KeyProperties.PURPOSE_ENCRYPT or - KeyProperties.PURPOSE_DECRYPT + keyAlias = keyAlias, + purposes = KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT, + keySize = KEY_SIZE, + digestAlgorithms = listOf( + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA512 ), description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY, - encryptionPadding = getEncryptionPaddings(), + encryptionPaddings = getEncryptionPaddings(), algorithm = RSA_ALGORITHM ) - } - private val keyGenParamSpecLegacy = KeyGenSpec( - algorithmParameterSpec = getLegacyKeyGenParamSpec(), + + private val keyGenParamSpecLegacy = LegacyKeyGenSpec( + context = context, + keyAlias = keyAlias, + keySize = KEY_SIZE, description = LEGACY_SPEC, - encryptionPadding = PKCS1_PADDING, + encryptionPaddings = listOf(PKCS1_PADDING), algorithm = RSA_ALGORITHM ) - /** - * Generates a legacy algorithm parameter specification using KeyPairGeneratorSpec. - * - * This approach is used for API levels below 23 (Android M) or as a fallback - * when more modern specifications fail. It creates a self-signed certificate - * with a 100-year validity period. - * - * @return A [KeyPairGeneratorSpec] configured for the key alias and application context - */ - private fun getLegacyKeyGenParamSpec(): AlgorithmParameterSpec { - // Generate a self-signed cert. - val certInfo = String.format( - Locale.ROOT, "CN=%s, OU=%s", - keyAlias, - context.packageName - ) - val start = Calendar.getInstance() - val end = Calendar.getInstance() - val certValidYears = 100 - end.add(Calendar.YEAR, certValidYears) - - return KeyPairGeneratorSpec.Builder(context) - .setAlias(keyAlias) - .setSubject(X500Principal(certInfo)) - .setSerialNumber(BigInteger.ONE) - .setStartDate(start.time) - .setEndDate(end.time) - .build() - } - - /** - * Helper method to create an appropriate key generation parameter specification. - * - * This method configures the specification with the appropriate padding and digest - * algorithms based on feature flags. It supports both OAEP (stronger) and PKCS1 - * (more compatible) padding schemes. - * - * @param purposes The key usage purposes (combinations of KeyProperties.PURPOSE_* constants) - * @return A [KeyGenParameterSpec] configured according to current settings - */ - @RequiresApi(api = Build.VERSION_CODES.M) - private fun createKeyGenParameterSpec(purposes: Int): AlgorithmParameterSpec { - return KeyGenParameterSpec.Builder(keyAlias, purposes) - .setKeySize(KEY_SIZE) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(getEncryptionPaddings()) - .build() - } init { val methodTag = "$TAG:init" @@ -218,17 +172,17 @@ class CryptoParameterSpecFactory( "Initialized with keyAlias: $keyAlias, API level: ${Build.VERSION.SDK_INT}, " + "With flight flags - PurposeWrapKey: $keySpecWithPurposeKey, " + "WithoutPurposeKey: $keySpecWithoutPurposeKey, " + - "WithOAEP: $supportsEncryptionPaddingRsaOaep" + "supportsKeyGenEncryptionPaddingRsaOaep: $supportsKeyGenEncryptionPaddingRsaOaep" ) } - @RequiresApi(Build.VERSION_CODES.M) - private fun getEncryptionPaddings(): String { - return if (supportsEncryptionPaddingRsaOaep) { - KeyProperties.ENCRYPTION_PADDING_RSA_OAEP - } else { - KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1 + + private fun getEncryptionPaddings(): List { + val paddings = mutableListOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + if (supportsKeyGenEncryptionPaddingRsaOaep) { + paddings.add(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) } + return paddings.toList() } /** @@ -264,10 +218,10 @@ class CryptoParameterSpecFactory( * * @return List of [KeyGenSpec] objects ordered by priority (highest first) */ - fun getPrioritizedKeyGenParameterSpecs(): List { + fun getPrioritizedKeyGenParameterSpecs(): List { val methodTag = "$TAG:getPrioritizedKeyGenParameterSpecs" - val specs = mutableListOf() + val specs = mutableListOf() // Add specs in order of preference if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && keySpecWithPurposeKey) { @@ -286,12 +240,4 @@ class CryptoParameterSpecFactory( Logger.info(methodTag, "Options: ${specs.joinToString { it.description }}") return specs.toList() } - - /** - * Returns the PKCS1 cipher specification. - * - */ - fun getPkcs1CipherSpec(): CipherSpec { - return pkcs1CipherSpec - } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt new file mode 100644 index 0000000000..5ee5141d88 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import android.content.Context +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import java.math.BigInteger +import java.security.spec.AlgorithmParameterSpec +import java.util.Calendar +import java.util.Locale +import javax.security.auth.x500.X500Principal + + +interface IKeyGenSpec { + val keyAlias: String + val description: String + val algorithm: String + val keySize: Int + val encryptionPaddings: List + val algorithmParameterSpec: AlgorithmParameterSpec + fun print(): String { + return "KeyGenSpec(description='$description', algorithm='$algorithm', encryptionPaddings='$encryptionPaddings')" + } +} + +/** + * Data class to hold parameter specifications for cryptographic key generation. + * + * This class encapsulates all the necessary information to generate a new cryptographic key pair, + * including a description for logging, the algorithm, the padding scheme to be associated with the key, + * and the detailed algorithm parameter specification. + * + * @property description A descriptive string for the specification, useful for logging and debugging. + * @property algorithm The key generation algorithm, typically "RSA". + * @property encryptionPaddings The encryption paddings supported for the key generation, + * (e.g., [android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1]). + * @property algorithmParameterSpec The detailed key generation parameter specification, such as + * [android.security.keystore.KeyGenParameterSpec] or [android.security.KeyPairGeneratorSpec]. + */ +data class KeyGenSpec( + private val purposes: Int, + private val digestAlgorithms: List, + override val keyAlias: String, + override val keySize: Int, + override val description: String, + override val algorithm: String, + override val encryptionPaddings: List, +) : IKeyGenSpec { + override fun toString() = print() + + private fun getDigestAlgorithms(): Array { + return digestAlgorithms.toTypedArray() + } + + private fun getEncryptionPaddings(): Array { + return encryptionPaddings.toTypedArray() + } + + /** + * Helper method to create an appropriate key generation parameter specification. + * + * This method configures the specification with the appropriate padding and digest + * algorithms based on feature flags. It supports both OAEP (stronger) and PKCS1 + * (more compatible) padding schemes. + * + * @return A [KeyGenParameterSpec] configured according to current settings + */ + override val algorithmParameterSpec: AlgorithmParameterSpec = + KeyGenParameterSpec.Builder(keyAlias, purposes) + .setKeySize(keySize) + .setDigests(*getDigestAlgorithms()) + .setEncryptionPaddings(*getEncryptionPaddings()) + .build() + +} + +data class LegacyKeyGenSpec( + private val context: Context, + override val keyAlias: String, + override val keySize: Int, + override val description: String, + override val algorithm: String, + override val encryptionPaddings: List, +) : IKeyGenSpec { + + override val algorithmParameterSpec = getLegacyKeyGenParamSpec() + + override fun toString() = print() + + private fun getLegacyKeyGenParamSpec(): AlgorithmParameterSpec { + // Generate a self-signed cert. + val certInfo = String.format( + Locale.ROOT, "CN=%s, OU=%s", + keyAlias, + context.packageName + ) + val start = Calendar.getInstance() + val end = Calendar.getInstance() + val certValidYears = 100 + end.add(Calendar.YEAR, certValidYears) + + return KeyPairGeneratorSpec.Builder(context) + .setAlias(keyAlias) + .setSubject(X500Principal(certInfo)) + .setSerialNumber(BigInteger.ONE) + .setStartDate(start.time) + .setEndDate(end.time) + .build() + } + +} + diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt deleted file mode 100644 index 49821761e0..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto - -import android.content.Context -import android.util.Log -import androidx.annotation.VisibleForTesting -import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil -import com.microsoft.identity.common.java.controllers.ExceptionAdapter -import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator.generateRandomKey -import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider -import com.microsoft.identity.common.java.crypto.key.KeyUtil -import com.microsoft.identity.common.java.exception.ClientException -import com.microsoft.identity.common.java.opentelemetry.AttributeName -import com.microsoft.identity.common.java.opentelemetry.OTelUtility -import com.microsoft.identity.common.java.opentelemetry.SpanExtension -import com.microsoft.identity.common.java.opentelemetry.SpanName -import com.microsoft.identity.common.java.util.FileUtil -import com.microsoft.identity.common.logging.Logger -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings -import io.opentelemetry.api.trace.StatusCode -import java.io.File -import java.security.KeyPair -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentMap -import javax.crypto.SecretKey - -/** - * This class doesn't really use the KeyStore-generated key directly. - * - * - * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key - * before it get saved to the file. - */ -class KeyStoreBackedSecretKeyProvider ( - override val alias: String, - private val mFilePath: String, - private val mContext: Context -) : ISecretKeyProvider { - - @get:VisibleForTesting - val keyFromCache: SecretKey? - get() = sKeyCacheMap[mFilePath] - - @VisibleForTesting - fun clearKeyFromCache() { - sKeyCacheMap.remove(mFilePath) - } - - @VisibleForTesting - @Throws(ClientException::class) - fun deleteSecretKeyAndCleanup() { - AndroidKeyStoreUtil.deleteKey(alias) - FileUtil.deleteFile(keyFile) - sKeyCacheMap.remove(mFilePath) - } - - private val keyFile: File - get() = File( - mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), - mFilePath - ) - - - private val cryptoParameterSpecFactory = CryptoParameterSpecFactory(mContext, alias) - - override val keyTypeIdentifier = WRAPPED_KEY_KEY_IDENTIFIER - - override val cipherTransformation = AES_CIPHER_TRANSFORMATION - - @get:Throws(ClientException::class) - @get:Synchronized - override val key: SecretKey - get() { - val methodTag = "$TAG:getKey" - if (!sSkipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists())) { - clearKeyFromCache() - } - // If key is on cache, return it. - keyFromCache?.let { keyFromCache -> - Logger.info( - methodTag, "Key is loaded from cache with thumbprint: " + - KeyUtil.getKeyThumbPrint(keyFromCache) - ) - return keyFromCache - } - // If key is in storage, load it. - Logger.info(methodTag, "Key not in cache or cache is empty.") - readSecretKeyFromStorage()?.let { keyFromStorage -> - Logger.info( - methodTag, "Key is loaded from storage with thumbprint: " + - KeyUtil.getKeyThumbPrint(keyFromStorage) +", caching it." - ) - sKeyCacheMap[mFilePath] = keyFromStorage - return keyFromStorage - } - Logger.info(methodTag, "Key does not exist in storage.") - val newKey = generateAndStoreSecretKey() - sKeyCacheMap[mFilePath] = newKey - return newKey - } - - @Throws(ClientException::class) - private fun generateAndStoreSecretKey(): SecretKey { - /* - * !!WARNING!! - * Multiple apps as of Today (1/4/2022) can still share a linux user id, by configuring - * the sharedUserId attribute in their Android Manifest file. If multiple apps reference - * the same value for sharedUserId and are signed with the same keys, they will use - * the same AndroidKeyStore and may obtain access to the files and shared preferences - * of other applications by invoking createPackageContext. - * - * Support for sharedUserId is deprecated, however some applications still use this Android capability. - * See: https://developer.android.com/guide/topics/manifest/manifest-element - * - * To address apps in this scenario we will attempt to load an existing KeyPair - * instead of immediately generating a new key pair. This will use the same keypair - * to encrypt the symmetric key generated separately for each - * application using a shared linux user id... and avoid these applications from - * stomping/overwriting one another's keypair. - */ - val methodTag = "$TAG:generateAndStoreSecretKey" - Logger.info(methodTag, "Generating a new SecretKey") - val newSecretKey = generateRandomKey() - - var keyPair = AndroidKeyStoreUtil.readKey(alias) - Logger.info(methodTag, "KeyPair is null: ${keyPair == null}") - if (keyPair == null) { - keyPair = generateKeyPair() - } - - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - Logger.info(TAG, "Wrapping key with cipher: $cipherParamsSpec") - val keyWrapped = AndroidKeyStoreUtil.wrap( - newSecretKey, - keyPair, - cipherParamsSpec.transformation, - cipherParamsSpec.algorithmParameterSpec - ) - - FileUtil.writeDataToFile(keyWrapped, keyFile) - Logger.info(methodTag, "New key is generated with thumbprint: " + KeyUtil.getKeyThumbPrint(newSecretKey)) - return newSecretKey - } - - - - - - /* package */@Synchronized - @Throws(ClientException::class) - fun readSecretKeyFromStorage(): SecretKey? { - val methodTag = "$TAG:readSecretKeyFromStorage" - try { - val keyPair = AndroidKeyStoreUtil.readKey(alias) - if (keyPair == null) { - Logger.info(methodTag, "key does not exist in keystore") - deleteSecretKeyAndCleanup() - return null - } - - val wrappedSecretKey = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) - if (wrappedSecretKey == null) { - Logger.warn(methodTag, "Key file is empty") - // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache - // to be deleted in Office because of sharedUserId allowing keystore to be shared amongst apps. - FileUtil.deleteFile(keyFile) - clearKeyFromCache() - return null - } - - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - val secretKey = AndroidKeyStoreUtil.unwrap( - wrappedSecretKey, - WRAP_KEY_ALGORITHM, - keyPair, - cipherParamsSpec.transformation, - cipherParamsSpec.algorithmParameterSpec - ) - Logger.info(methodTag, "secretkey algorithm: ${secretKey.algorithm}, " + - "key size: ${secretKey.encoded.size}, " + - "key thumbprint: ${KeyUtil.getKeyThumbPrint(secretKey)}" - ) - return secretKey - } catch (e: ClientException) { - // Reset KeyPair info so that new request will generate correct KeyPairs. - // All tokens with previous SecretKey are not possible to decrypt. - Logger.warn( - methodTag, "Error when loading key from Storage, " + - "wipe all existing key data " - ) - deleteSecretKeyAndCleanup() - throw e - } - } - - - - - /** - * Selects the most appropriate [CipherSpec] for the given [KeyPair] by matching the supported - * encryption paddings from the Android Keystore with a prioritized list of available cipher specs. - * - * This function attempts to find a compatible cipher configuration for key wrapping by: - * 1. Fetching the encryption paddings supported by the provided [keyPair]. - * 2. Iterating through the prioritized list of [CipherSpec]s. - * 3. Returning the first compatible spec where the padding is supported by the key. - * - * If no matching specification is found, a fallback using PKCS#1 padding is returned. - * - * @param keyPair The [KeyPair] for which a compatible [CipherSpec] should be determined. - * @return A compatible [CipherSpec], or a fallback to a PKCS#1-based spec if none are supported. - */ - private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { - val methodTag = "$TAG:selectCompatibleCipherSpec" - val supportedPaddings = AndroidKeyStoreUtil.getEncryptionPaddings(keyPair) - val availableSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() - Logger.info(TAG, - "Supported paddings by the keyPair: $supportedPaddings" + - ",Specs available in order of priority: $availableSpecs" - ) - for (spec in availableSpecs) { - for (padding in supportedPaddings) { - if (spec.padding.contains(padding, ignoreCase = true)) { - return spec - } - } - } - Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") - // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. - return cryptoParameterSpecFactory.getPkcs1CipherSpec() - } - - /** - * Generates a new RSA key pair and stores it in the Android KeyStore. - * - * - * This method attempts to generate a key pair using multiple key generation specifications - * in order of preference. If the primary specification fails, it will attempt fallback - * specifications to ensure compatibility across different Android versions and devices. - * - * - * The key generation process is traced using OpenTelemetry for monitoring and diagnostics. - * - * @return A new RSA KeyPair stored in the Android KeyStore - * @throws ClientException if all key generation attempts fail - */ - @Throws(ClientException::class) - private fun generateKeyPair(): KeyPair { - val methodTag = "${TAG}:generateKeyPair" - val span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name, SpanExtension.current().spanContext) - val failures = mutableListOf() - val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() - - try { - SpanExtension.makeCurrentSpan(span).use { _ -> - for (spec in specs) { - try { - val keypairGenStartTime = System.currentTimeMillis() - val keyPair = AndroidKeyStoreUtil.generateKeyPair( - spec.algorithm, - spec.algorithmParameterSpec - ) - val elapsedTime = System.currentTimeMillis() - keypairGenStartTime - SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) - span.setStatus(StatusCode.OK) - Log.i(TAG, "Key pair generated successfully with spec: $spec ") - return keyPair - } catch (throwable: Throwable) { - Logger.warn(methodTag, "Failed to generate key pair with spec: $spec") - failures.add(throwable) - } - } - - // If we reach here, all attempts have failed - failures.forEach { exception -> - Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) - } - val finalError = failures.lastOrNull() ?: ClientException( - ClientException.UNKNOWN_CRYPTO_ERROR, - "Key pair generation failed after trying all available specs." - ) - span.setStatus(StatusCode.ERROR) - span.recordException(finalError) - throw ExceptionAdapter.clientExceptionFromException(finalError) - } - } finally { - span.end() - } - } - - companion object { - private val TAG = KeyStoreBackedSecretKeyProvider::class.java.simpleName - - /** - * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in - * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here - * probably doing PKCS7. We decide to go with Java default string. - */ - const val AES_CIPHER_TRANSFORMATION: String = "AES/CBC/PKCS5Padding" - - /** - * Should KeyStore and key file check for validity before every key load be skipped. - */ - @SuppressFBWarnings("MS_SHOULD_BE_FINAL") - var sSkipKeyInvalidationCheck: Boolean = false - - /** - * Algorithm for the wrapping key itself. - */ - private const val WRAP_KEY_ALGORITHM = "RSA" - - /** - * Indicate that token item is encrypted with the key loaded in this class. - */ - const val WRAPPED_KEY_KEY_IDENTIFIER : String = "A001" - - @VisibleForTesting - const val KEY_FILE_SIZE: Int = 1024 - - /** - * SecretKey cache. Maps wrapped secret key file path to the SecretKey. - */ - private val sKeyCacheMap: ConcurrentMap = ConcurrentHashMap() - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt index 91fb61d80a..883fce7560 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt @@ -23,12 +23,7 @@ package com.microsoft.identity.common.crypto import android.content.Context -import android.os.Build -import android.security.KeyPairGeneratorSpec -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import android.util.Log -import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil import com.microsoft.identity.common.java.controllers.ExceptionAdapter @@ -36,27 +31,19 @@ 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.KeyUtil import com.microsoft.identity.common.java.exception.ClientException -import com.microsoft.identity.common.java.flighting.CommonFlight -import com.microsoft.identity.common.java.flighting.CommonFlightsManager.getFlightsProvider import com.microsoft.identity.common.java.opentelemetry.AttributeName import com.microsoft.identity.common.java.opentelemetry.OTelUtility import com.microsoft.identity.common.java.opentelemetry.SpanExtension import com.microsoft.identity.common.java.opentelemetry.SpanName import com.microsoft.identity.common.java.util.FileUtil -import com.microsoft.identity.common.java.util.StringUtil import com.microsoft.identity.common.logging.Logger import edu.umd.cs.findbugs.annotations.SuppressFBWarnings import io.opentelemetry.api.trace.StatusCode import java.io.File -import java.math.BigInteger import java.security.KeyPair -import java.security.spec.AlgorithmParameterSpec -import java.util.Calendar -import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import javax.crypto.SecretKey -import javax.security.auth.x500.X500Principal /** * This class doesn't really use the KeyStore-generated key directly. @@ -237,179 +224,15 @@ class NewAndroidWrappedKeyProvider( } - /** - * Generate a new key pair wrapping key, based on API level uses different spec to generate - * the key pair. - * @return a key pair - */ - @Throws(ClientException::class) - private fun generateNewKeyPair(): KeyPair { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - generateNewKeyPairAPI28AndAbove() - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - generateNewKeyPairAPI23AndAbove() - } else { - generateKeyPairWithLegacySpec() - } - } - /** - * Call this for API level >= 28. Starting level API 28 PURPOSE_WRAP_KEY is added. Based on flights - * this method may or may not use the PURPOSE_WRAP_KEY along with PURPOSE_ENCRYPT and PURPOSE_DECRYPT. The logic - * if (wrap key flight enabled) use all three purposes - * else if (new key gen flight enabled) use only encrypt and decrypt purposes - * else use legacy spec. - * @return key pair - */ - @RequiresApi(Build.VERSION_CODES.P) - @Throws( - ClientException::class - ) - private fun generateNewKeyPairAPI28AndAbove(): KeyPair { - return if (getFlightsProvider() - .isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) - ) { - generateWrappingKeyPair_WithPurposeWrapKey() - } else if (getFlightsProvider() - .isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - ) { - generateWrappingKeyPair() - } else { - generateKeyPairWithLegacySpec() - } - } - /** - * Call this for API level >= 23. Based on flight new key gen spec is used else legacy which - * is deprecated starting API 23. - * @return key pair - */ - @RequiresApi(Build.VERSION_CODES.M) - @Throws( - ClientException::class - ) - private fun generateNewKeyPairAPI23AndAbove(): KeyPair { - return if (getFlightsProvider() - .isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - ) { - generateWrappingKeyPair() - } else { - generateKeyPairWithLegacySpec() - } - } - /** - * Generate a new key pair wrapping key based on legacy logic. Call this for API < 23 or as fallback - * until new key gen specs are stable. - * @return key pair generated with legacy spec - * @throws ClientException if there is an error generating the key pair. - */ - @Throws(ClientException::class) - private fun generateKeyPairWithLegacySpec(): KeyPair { - val span = SpanExtension.current() - try { - val keyPairGenSpec = - legacySpecForKeyStoreKey - val keyPair = attemptKeyPairGeneration(keyPairGenSpec) - span.setAttribute( - AttributeName.key_pair_gen_successful_method.name, - "legacy_key_gen_spec" - ) - return keyPair - } catch (e: Throwable) { - Logger.error( - TAG + ":generateKeyPairWithLegacySpec", - "Error generating keypair with legacy spec.", - e - ) - throw ExceptionAdapter.clientExceptionFromException(e) - } - } - /** - * Generate a new key pair wrapping key, based on API level >= 28. This method uses new key gen spec - * with PURPOSE_WRAP_KEY. If this fails, it will fallback to generateWrappingKeyPair() which does not use - * PURPOSE_WRAP_KEY (still uses new key gen spec). - */ - @RequiresApi(Build.VERSION_CODES.P) - @Throws( - ClientException::class - ) - private fun generateWrappingKeyPair_WithPurposeWrapKey(): KeyPair { - val methodTag = TAG + ":generateWrappingKeyPair_WithPurposeWrapKey" - val span = SpanExtension.current() - try { - Logger.info(methodTag, "Generating new keypair with new spec with purpose_wrap_key") - val purposes = - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_WRAP_KEY - val keyPairGenSpec = getSpecForWrappingKey(purposes) - val keyPair = attemptKeyPairGeneration(keyPairGenSpec) - span.setAttribute( - AttributeName.key_pair_gen_successful_method.name, - "new_key_gen_spec_with_wrap" - ) - return keyPair - } catch (e: Throwable) { - Logger.error( - methodTag, "Error generating keypair with new spec with purpose_wrap_key." + - "Attempting without purpose_wrap_key.", e - ) - if (!StringUtil.isNullOrEmpty(e.message)) { - span.setAttribute(AttributeName.keypair_gen_exception.name, e.message) - } - return generateWrappingKeyPair() - } - } - /** - * Generate a new key pair wrapping key, based on API level >= 23. This method uses new key gen spec - * with purposes PURPOSE_ENCRYPT and PURPOSE_DECRYPT. If this fails, it will fallback to generateKeyPairWithLegacySpec() - * which uses olg key gen spec. - */ - @RequiresApi(Build.VERSION_CODES.M) - @Throws( - ClientException::class - ) - private fun generateWrappingKeyPair(): KeyPair { - val methodTag = TAG + ":generateWrappingKeyPair" - val span = SpanExtension.current() - try { - Logger.info(methodTag, "Generating new keypair with new spec without wrap key") - val purposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - val keyPairGenSpec = getSpecForWrappingKey(purposes) - val keyPair = attemptKeyPairGeneration(keyPairGenSpec) - span.setAttribute( - AttributeName.key_pair_gen_successful_method.name, - "new_key_gen_spec_without_wrap" - ) - return keyPair - } catch (e: Throwable) { - Logger.error( - methodTag, "Error generating keypair with new spec." + - "Attempting with legacy spec.", e - ) - if (!StringUtil.isNullOrEmpty(e.message)) { - span.setAttribute(AttributeName.keypair_gen_exception.name, e.message) - } - return generateKeyPairWithLegacySpec() - } - } - @Throws(ClientException::class) - private fun attemptKeyPairGeneration(keyPairGenSpec: AlgorithmParameterSpec): KeyPair { - val keypairGenStartTime = System.currentTimeMillis() - val keyPair = AndroidKeyStoreUtil.generateKeyPair( - WRAP_KEY_ALGORITHM, keyPairGenSpec - ) - recordKeyGenerationTime(keypairGenStartTime) - return keyPair - } - private fun recordKeyGenerationTime(keypairGenStartTime: Long) { - val elapsedTime = System.currentTimeMillis() - keypairGenStartTime - SpanExtension.current() - .setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) - } + + /** * Wipe all the data associated from this key. @@ -422,64 +245,27 @@ class NewAndroidWrappedKeyProvider( sKeyCacheMap.remove(mFilePath) } - private val legacySpecForKeyStoreKey: AlgorithmParameterSpec - /** - * Generate a self-signed cert and derive an AlgorithmParameterSpec from that. - * This is for the key to be generated in [KeyStore] via [KeyPairGenerator] - * Note : This is now only for API level < 23 or as fallback. - * - * @return a [AlgorithmParameterSpec] for the keystore key (that we'll use to wrap the secret key). - */ - get() { - // Generate a self-signed cert. - val certInfo = String.format( - Locale.ROOT, "CN=%s, OU=%s", - alias, - mContext.packageName - ) - - val start = Calendar.getInstance() - val end = Calendar.getInstance() - val certValidYears = 100 - end.add(Calendar.YEAR, certValidYears) - - return KeyPairGeneratorSpec.Builder(mContext) - .setAlias(alias) - .setSubject(X500Principal(certInfo)) - .setSerialNumber(BigInteger.ONE) - .setStartDate(start.time) - .setEndDate(end.time) - .build() - } - @RequiresApi(api = Build.VERSION_CODES.M) - private fun getSpecForWrappingKey(purposes: Int): AlgorithmParameterSpec { - return KeyGenParameterSpec.Builder(alias, purposes) - .setKeySize(2048) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) - .build() - } private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { val methodTag = "$TAG:selectCompatibleCipherSpec" val supportedPaddings = AndroidKeyStoreUtil.getEncryptionPaddings(keyPair) - val availableSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() + val availableCipherSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() Logger.verbose( methodTag, "Supported paddings by the keyPair: $supportedPaddings" + - ",Specs available in order of priority: $availableSpecs" + ",Specs available in order of priority: $availableCipherSpecs" ) - for (spec in availableSpecs) { + for (cipherSpec in availableCipherSpecs) { for (padding in supportedPaddings) { - if (spec.padding.contains(padding, ignoreCase = true)) { - return spec + if (cipherSpec.padding.contains(padding, ignoreCase = true)) { + return cipherSpec } } } Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. - return cryptoParameterSpecFactory.getPkcs1CipherSpec() + return cryptoParameterSpecFactory.pkcs1CipherSpec } @@ -554,15 +340,6 @@ class NewAndroidWrappedKeyProvider( @SuppressFBWarnings("MS_SHOULD_BE_FINAL") var sSkipKeyInvalidationCheck: Boolean = false - /** - * Algorithm for key wrapping. - */ - private const val WRAP_ALGORITHM = "RSA/ECB/PKCS1Padding" - - /** - * Algorithm for the wrapping key itself. - */ - private const val WRAP_KEY_ALGORITHM = "RSA" /** * Indicate that token item is encrypted with the key loaded in this class. From a80d4a68c10b7b06200c734e2e2df8d12b886001 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 14 Jul 2025 11:15:13 -0700 Subject: [PATCH 20/51] feat: Replace NewAndroidWrappedKeyProvider with KeyStoreBackedSecretKeyProvider in tests; add KeyStoreBackedSecretKeyProvider and factory for key management. --- .../crypto/AndroidWrappedKeyProviderTest.java | 34 +++++++++---------- ....kt => KeyStoreBackedSecretKeyProvider.kt} | 0 ...KeyStoreBackedSecretKeyProviderFactory.kt} | 22 ++---------- 3 files changed, 19 insertions(+), 37 deletions(-) rename common/src/main/java/com/microsoft/identity/common/crypto/{NewAndroidWrappedKeyProvider.kt => KeyStoreBackedSecretKeyProvider.kt} (100%) rename common/src/main/java/com/microsoft/identity/common/crypto/{AndroidWrappedKeyProviderFactory.kt => KeyStoreBackedSecretKeyProviderFactory.kt} (67%) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index 33cef72270..8fda5264f7 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -125,7 +125,7 @@ private AlgorithmParameterSpec getMockKeyPairGeneratorSpec(final String alias) { @Test public void testGenerateKey() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyProvider.generateNewSecretKey(); Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); @@ -133,7 +133,7 @@ public void testGenerateKey() throws ClientException { @Test public void testReadKeyDirectly() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final KeyStoreBackedSecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); final SecretKey secretKey = keyProvider.getKey(); final SecretKey storedSecretKey = keyProvider.readSecretKeyFromStorage(); @@ -153,9 +153,9 @@ public void testReadKeyDirectly() throws ClientException { public void testLoadKey() throws ClientException { // Nothing exists. This load key function should generate a key if the key hasn't exist. Assert.assertNull(AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); - Assert.assertNull(FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE)); + Assert.assertNull(FileUtil.readFromFile(getKeyFile(), KeyStoreBackedSecretKeyProvider.KEY_FILE_SIZE)); - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey secretKey = keyProvider.getKey(); final SecretKey key = keyProvider.getKeyFromCache(); @@ -168,10 +168,10 @@ public void testLoadKey() throws ClientException { @Test public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientException { // Create a new Keystore-wrapped key. - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); keyProvider.generateNewSecretKey(); - final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE); + final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), KeyStoreBackedSecretKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); // Overwrite the key file with corrupted data. @@ -195,10 +195,10 @@ public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientExce @Test public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException { // Create a new Keystore-wrapped key. - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); keyProvider.generateNewSecretKey(); - final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), NewAndroidWrappedKeyProvider.KEY_FILE_SIZE); + final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), KeyStoreBackedSecretKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); // Overwrite the key file with corrupted data. @@ -223,7 +223,7 @@ public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException @Test @Ignore public void testPerf_WithCachedKey() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); long timeStartLoop = System.nanoTime(); for (int i = 0; i < TEST_LOOP; i++) { @@ -238,7 +238,7 @@ public void testPerf_WithCachedKey() throws ClientException { @Test @Ignore public void testPerf_NoCachedKey() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); long timeStartLoopNotCached = System.nanoTime(); for (int i = 0; i < 100; i++) { @@ -255,7 +255,7 @@ public void testPerf_NoCachedKey() throws ClientException { */ @Test public void testLoadDeletedKeyStoreKey() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final KeyStoreBackedSecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); @@ -266,7 +266,7 @@ public void testLoadDeletedKeyStoreKey() throws ClientException { @Test public void testLoadDeletedKeyFile() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final KeyStoreBackedSecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); FileUtil.deleteFile(getKeyFile()); @@ -315,8 +315,8 @@ public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProv } - private NewAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws ClientException { - final NewAndroidWrappedKeyProvider keyProvider = new NewAndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + private KeyStoreBackedSecretKeyProvider initkeyProviderWithKeyEntry() throws ClientException { + final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey key = keyProvider.getKey(); Assert.assertNotNull(key); Assert.assertNotNull(keyProvider.getKeyFromCache()); @@ -324,14 +324,14 @@ private NewAndroidWrappedKeyProvider initkeyProviderWithKeyEntry() throws Client } /** - * Helper method to generate a random key using NewAndroidWrappedKeyProvider. + * Helper method to generate a random key using KeyStoreBackedSecretKeyProvider. * This method is used to substitute the call to keyLoader.generateRandomKey() in tests. * - * @param keyLoader The NewAndroidWrappedKeyProvider instance to use + * @param keyLoader The KeyStoreBackedSecretKeyProvider instance to use * @return The generated SecretKey * @throws ClientException if key generation fails */ - private SecretKey generateRandomKey(NewAndroidWrappedKeyProvider keyLoader) throws ClientException { + private SecretKey generateRandomKey(KeyStoreBackedSecretKeyProvider keyLoader) throws ClientException { // Get the key will generate a new one if it doesn't exist SecretKey key = keyLoader.getKey(); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt similarity index 100% rename from common/src/main/java/com/microsoft/identity/common/crypto/NewAndroidWrappedKeyProvider.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt similarity index 67% rename from common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt index be116f7135..e79fb40727 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt @@ -26,28 +26,10 @@ import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager -/** - * Factory class for creating wrapped key loaders specific to the Android platform. - * - * This object is responsible for creating the appropriate implementation of [ISecretKeyProvider] - * based on feature flag. It abstracts away the details of which - * specific loader implementation should be used, allowing for runtime switching between - * different implementations without affecting client code. - */ + object AndroidWrappedKeyProviderFactory { var skipKeyInvalidationCheck: Boolean = false - /** - * Creates an appropriate wrapped key loader instance based on current feature flags. - * - * This method checks the [CommonFlight.ENABLE_NEW_ANDROID_WRAPPED_KEY_LOADER] feature flag - * to determine whether to use the new implementation or the legacy implementation of - * the Android wrapped key loader. - * - * @param keyIdentifier A unique identifier for the key being loaded - * @param fileName The name of the file where the wrapped key is stored - * @param context The Android application context needed for file and security operations - * @return An implementation of [ISecretKeyProvider] that can load the specified wrapped key - */ + fun createWrappedKeyProvider( keyIdentifier: String, fileName: String, From db44184b5598f9b65cc4ed65793b8be7749d31d8 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 14 Jul 2025 12:08:40 -0700 Subject: [PATCH 21/51] feat: Refactor cryptographic key handling and enhance logging - Introduced `KeyStoreBackedSecretKeyProvider` to replace the legacy `AndroidWrappedKeyProvider`, improving security with enhanced encryption paddings. - Updated `CryptoParameterSpecFactory` to include detailed initialization logging and improved cipher specification handling. - Refactored `KeyGenSpec` and `LegacyKeyGenSpec` for better clarity and added documentation. - Enhanced `AndroidKeyStoreUtil` to provide clearer methods for retrieving encryption paddings from key pairs. - Updated tests to reflect changes in method names and ensure compatibility with the new key provider. - Added telemetry attributes for secret key wrapping operations to improve observability. - Modified feature flags to control the use of the new key provider implementation. --- .../crypto/AndroidWrappedKeyProvider.java | 9 +- .../identity/common/crypto/CipherSpec.kt | 24 +- .../crypto/CryptoParameterSpecFactory.kt | 73 ++-- .../identity/common/crypto/KeyGenSpec.kt | 58 ++- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 375 +++++++++++------- .../KeyStoreBackedSecretKeyProviderFactory.kt | 52 ++- .../internal/util/AndroidKeyStoreUtil.java | 18 +- .../util/AndroidKeyStoreUtilTest.java | 44 +- .../common/java/flighting/CommonFlight.java | 2 +- .../java/opentelemetry/AttributeName.java | 9 +- .../common/java/opentelemetry/SpanName.java | 2 +- 11 files changed, 403 insertions(+), 263 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java index 05ca411de5..7c969a6765 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java @@ -62,7 +62,6 @@ import javax.security.auth.x500.X500Principal; import edu.umd.cs.findbugs.annotations.Nullable; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.context.Scope; @@ -85,12 +84,6 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { private static final String TAG = AndroidWrappedKeyProvider.class.getSimpleName() + "#"; - /** - * Should KeyStore and key file check for validity before every key load be skipped. - */ - @SuppressFBWarnings("MS_SHOULD_BE_FINAL") - public static boolean sSkipKeyInvalidationCheck = false; - /** * Algorithm for key wrapping. */ @@ -125,7 +118,7 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { private final CachedData mKeyCache = new CachedData() { @Override public SecretKey getData() { - if (!sSkipKeyInvalidationCheck && + if (!KeyStoreBackedSecretKeyProviderFactory.skipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !getKeyFile().exists())) { this.clear(); } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt index f8ff43097a..45a2a0d3b1 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt @@ -24,20 +24,18 @@ package com.microsoft.identity.common.crypto import java.security.spec.AlgorithmParameterSpec - - /** - * Data class to hold cipher parameter specifications for encryption and decryption operations. + * Cipher parameter specification for cryptographic operations. * - * This class defines the components needed to create a [javax.crypto.Cipher] instance, - * including the algorithm, block mode, and padding scheme. It also constructs the full - * transformation string required by the Cipher API. + * Encapsulates the components required to initialize a [javax.crypto.Cipher] instance: + * algorithm, mode, padding, and optional algorithm parameters. * - * @property algorithmParameterSpec The algorithm parameter specification (e.g., [javax.crypto.spec.OAEPParameterSpec]), - * which can be null if not required by the transformation. - * @property algorithm The name of the cryptographic algorithm (e.g., "RSA"). - * @property mode The block cipher mode of operation (e.g., "ECB", "CBC"). - * @property padding The padding scheme used for the cipher (e.g., "PKCS1Padding", "OAEPwithSHA-256andMGF1Padding"). + * @property algorithmParameterSpec Optional algorithm parameters (e.g., [javax.crypto.spec.OAEPParameterSpec]) + * @property algorithm Cryptographic algorithm (e.g., "RSA") + * @property mode Block cipher mode (e.g., "ECB", "CBC") + * @property padding Padding scheme (e.g., "PKCS1Padding", "OAEPwithSHA-256andMGF1Padding") + * + * @see javax.crypto.Cipher */ data class CipherSpec( val algorithmParameterSpec: AlgorithmParameterSpec?, @@ -46,8 +44,7 @@ data class CipherSpec( val padding: String, ) { /** - * The full transformation string (e.g., "RSA/ECB/PKCS1Padding") used to initialize a - * [javax.crypto.Cipher] instance. + * Complete transformation string for [javax.crypto.Cipher.getInstance]. */ val transformation = "$algorithm/$mode/$padding" @@ -55,4 +52,3 @@ data class CipherSpec( return "CipherSpec(transformation='$transformation')" } } - diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index 0d3db87486..ccfbaf32b5 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -101,9 +101,20 @@ class CryptoParameterSpecFactory( flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) private val keySpecWithoutPurposeKey = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - private val supportsKeyGenEncryptionPaddingRsaOaep = + private val enableKeyGenEncryptionPaddingRsaOaep = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) + init { + val methodTag = "$TAG:init" + Logger.verbose( + methodTag, + "Initialized CryptoParameterSpecFactory - " + + "API: ${Build.VERSION.SDK_INT}, " + + "flags: [purposeKey=$keySpecWithPurposeKey, " + + "withoutPurposeKey=$keySpecWithoutPurposeKey, " + + "oaepSupported=$enableKeyGenEncryptionPaddingRsaOaep]" + ) + } // Cipher parameter specifications val pkcs1CipherSpec = CipherSpec( @@ -120,7 +131,7 @@ class CryptoParameterSpecFactory( padding = OAEP_PADDING_WITH_256MGF1, ) - + // Key generation parameter specifications @RequiresApi(Build.VERSION_CODES.P) private val keyGenParamSpecWithPurposeWrapKey = KeyGenSpec( @@ -134,7 +145,7 @@ class CryptoParameterSpecFactory( KeyProperties.DIGEST_SHA512 ), description = MODERN_SPEC_WITH_PURPOSE_WRAP_KEY, - encryptionPaddings = getEncryptionPaddings(), + encryptionPaddings = getEncryptionPaddingsForKeyGen(), algorithm = RSA_ALGORITHM ) @@ -150,11 +161,10 @@ class CryptoParameterSpecFactory( KeyProperties.DIGEST_SHA512 ), description = MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY, - encryptionPaddings = getEncryptionPaddings(), + encryptionPaddings = getEncryptionPaddingsForKeyGen(), algorithm = RSA_ALGORITHM ) - private val keyGenParamSpecLegacy = LegacyKeyGenSpec( context = context, keyAlias = keyAlias, @@ -164,36 +174,23 @@ class CryptoParameterSpecFactory( algorithm = RSA_ALGORITHM ) - - init { - val methodTag = "$TAG:init" - Logger.info( - methodTag, - "Initialized with keyAlias: $keyAlias, API level: ${Build.VERSION.SDK_INT}, " + - "With flight flags - PurposeWrapKey: $keySpecWithPurposeKey, " + - "WithoutPurposeKey: $keySpecWithoutPurposeKey, " + - "supportsKeyGenEncryptionPaddingRsaOaep: $supportsKeyGenEncryptionPaddingRsaOaep" - ) - } - - - private fun getEncryptionPaddings(): List { + //TODO: check if additional flag is needed to enable OAEP padding + private fun getEncryptionPaddingsForKeyGen(): List { val paddings = mutableListOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) - if (supportsKeyGenEncryptionPaddingRsaOaep) { + if (enableKeyGenEncryptionPaddingRsaOaep) { paddings.add(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) } - return paddings.toList() + return paddings } + /** - * Returns a prioritized list of cipher parameter specifications to try in sequence. - * - * This allows the calling code to attempt operations with the most secure/preferred - * specification first, then fall back to more compatible options if needed. + * Returns cipher specifications ordered by security preference. * - * The list is ordered with the most preferred specification first (OAEP if enabled, then PKCS1). + * The list prioritizes OAEP padding (when enabled by feature flag) over PKCS1 + * for better security, with PKCS1 always included for compatibility. * - * @return List of [CipherSpec] objects ordered by preference (highest priority first) + * @return List of [CipherSpec] objects ordered by preference (most secure first) */ fun getPrioritizedCipherParameterSpecs(): List { val methodTag = "$TAG:getPrioritizedCipherParameterSpecs" @@ -202,25 +199,17 @@ class CryptoParameterSpecFactory( return specs } + /** - * Returns a prioritized list of key generation parameter specifications to try in sequence. - * - * This helps handle fallback scenarios where the preferred spec might not work - * on all devices or with all existing keys. Each specification has a descriptive - * identifier for logging and debugging purposes. - * - * The method considers: - * 1. Android API level (supporting modern APIs from Android M/23 and P/28) - * 2. Feature flags that enable/disable specific key generation approaches - * 3. Backward compatibility with existing keys + * Returns key generation specifications ordered by API level compatibility. * - * The list always includes a legacy specification as a last resort fallback option. + * Prioritizes modern Android KeyStore features (API 23+) when enabled by feature flags, + * with legacy fallback always included for maximum compatibility. * - * @return List of [KeyGenSpec] objects ordered by priority (highest first) + * @return List of [IKeyGenSpec] objects ordered by preference (most modern first) */ fun getPrioritizedKeyGenParameterSpecs(): List { val methodTag = "$TAG:getPrioritizedKeyGenParameterSpecs" - val specs = mutableListOf() // Add specs in order of preference @@ -237,7 +226,7 @@ class CryptoParameterSpecFactory( // Always include legacy spec as last resort fallback specs.add(keyGenParamSpecLegacy) - Logger.info(methodTag, "Options: ${specs.joinToString { it.description }}") - return specs.toList() + Logger.info(methodTag, "Key generation specs: ${specs.joinToString { it.description }}") + return specs } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt index 5ee5141d88..815174c263 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt @@ -31,7 +31,9 @@ import java.util.Calendar import java.util.Locale import javax.security.auth.x500.X500Principal - +/** + * Interface for key generation specifications. + */ interface IKeyGenSpec { val keyAlias: String val description: String @@ -45,18 +47,17 @@ interface IKeyGenSpec { } /** - * Data class to hold parameter specifications for cryptographic key generation. + * Modern key generation specification for Android API 23+. * - * This class encapsulates all the necessary information to generate a new cryptographic key pair, - * including a description for logging, the algorithm, the padding scheme to be associated with the key, - * and the detailed algorithm parameter specification. + * Uses [KeyGenParameterSpec] with configurable purposes, digest algorithms, and encryption paddings. * - * @property description A descriptive string for the specification, useful for logging and debugging. - * @property algorithm The key generation algorithm, typically "RSA". - * @property encryptionPaddings The encryption paddings supported for the key generation, - * (e.g., [android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1]). - * @property algorithmParameterSpec The detailed key generation parameter specification, such as - * [android.security.keystore.KeyGenParameterSpec] or [android.security.KeyPairGeneratorSpec]. + * @property purposes Key usage purposes (e.g., ENCRYPT, DECRYPT, WRAP_KEY) + * @property digestAlgorithms Supported digest algorithms (e.g., SHA-256, SHA-512) + * @property keyAlias Unique identifier for the key in KeyStore + * @property keySize RSA key size in bits (typically 2048) + * @property description Human-readable identifier for logging + * @property algorithm Key generation algorithm (typically "RSA") + * @property encryptionPaddings Supported padding schemes (e.g., PKCS1, OAEP) */ data class KeyGenSpec( private val purposes: Int, @@ -69,23 +70,20 @@ data class KeyGenSpec( ) : IKeyGenSpec { override fun toString() = print() + /** + * Converts digest algorithms list to array format required by KeyGenParameterSpec. + */ private fun getDigestAlgorithms(): Array { return digestAlgorithms.toTypedArray() } + /** + * Converts encryption paddings list to array format required by KeyGenParameterSpec. + */ private fun getEncryptionPaddings(): Array { return encryptionPaddings.toTypedArray() } - /** - * Helper method to create an appropriate key generation parameter specification. - * - * This method configures the specification with the appropriate padding and digest - * algorithms based on feature flags. It supports both OAEP (stronger) and PKCS1 - * (more compatible) padding schemes. - * - * @return A [KeyGenParameterSpec] configured according to current settings - */ override val algorithmParameterSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(keyAlias, purposes) .setKeySize(keySize) @@ -95,6 +93,19 @@ data class KeyGenSpec( } +/** + * Legacy key generation specification for Android API < 23. + * + * Uses [KeyPairGeneratorSpec] to generate self-signed certificates with 100-year validity. + * Provides fallback compatibility for devices that don't support modern KeyStore APIs. + * + * @property context Android context for KeyStore access + * @property keyAlias Unique identifier for the key in KeyStore + * @property keySize RSA key size in bits (typically 2048) + * @property description Human-readable identifier for logging + * @property algorithm Key generation algorithm (typically "RSA") + * @property encryptionPaddings Supported padding schemes (typically PKCS1 only) + */ data class LegacyKeyGenSpec( private val context: Context, override val keyAlias: String, @@ -108,6 +119,11 @@ data class LegacyKeyGenSpec( override fun toString() = print() + /** + * Creates legacy KeyPairGeneratorSpec with self-signed certificate. + * + * Generates certificate with 100-year validity using app package name as issuer. + */ private fun getLegacyKeyGenParamSpec(): AlgorithmParameterSpec { // Generate a self-signed cert. val certInfo = String.format( @@ -128,6 +144,4 @@ data class LegacyKeyGenSpec( .setEndDate(end.time) .build() } - } - diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index 883fce7560..e5fd0b4feb 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -23,7 +23,6 @@ package com.microsoft.identity.common.crypto import android.content.Context -import android.util.Log import androidx.annotation.VisibleForTesting import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil import com.microsoft.identity.common.java.controllers.ExceptionAdapter @@ -37,7 +36,6 @@ import com.microsoft.identity.common.java.opentelemetry.SpanExtension import com.microsoft.identity.common.java.opentelemetry.SpanName import com.microsoft.identity.common.java.util.FileUtil import com.microsoft.identity.common.logging.Logger -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings import io.opentelemetry.api.trace.StatusCode import java.io.File import java.security.KeyPair @@ -46,21 +44,56 @@ import java.util.concurrent.ConcurrentMap import javax.crypto.SecretKey /** - * This class doesn't really use the KeyStore-generated key directly. + * A secret key provider that uses Android KeyStore to store and retrieve the secret key. + * The secret key is wrapped using a KeyPair stored in the Android KeyStore. * - * - * Instead, the actual key that we use to encrypt/decrypt data is 'wrapped/encrypted' with the keystore key - * before it get saved to the file. + * @param alias The alias for the key in the Android KeyStore. + * @param mFilePath The file path where the wrapped secret key is stored. + * @param mContext The context used to access the Android KeyStore and file system. */ -class NewAndroidWrappedKeyProvider( +class KeyStoreBackedSecretKeyProvider( override val alias: String, private val mFilePath: String, - private val mContext: Context + mContext: Context ) : ISecretKeyProvider { + companion object { + private const val TAG = "KeyStoreBackedSecretKeyProvider" + /** + * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in + * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here + * probably doing PKCS7. We decide to go with Java default string. + */ + const val AES_CBC_PKCS5_PADDING_TRANSFORMATION: String = "AES/CBC/PKCS5Padding" + + /** + * Indicate that token item is encrypted with the key loaded in this class. + */ + const val KEY_TYPE_IDENTIFIER: String = "A001" + + @VisibleForTesting + const val KEY_FILE_SIZE: Int = 1024 + + /** + * SecretKey cache. Maps wrapped secret key file path to the SecretKey. + */ + private val sKeyCacheMap: ConcurrentMap = ConcurrentHashMap() + } + override val keyTypeIdentifier = KEY_TYPE_IDENTIFIER override val cipherTransformation = AES_CBC_PKCS5_PADDING_TRANSFORMATION + + /** + * CryptoParameterSpecFactory is used to select the compatible cipher spec for wrapping/unwrapping the secret key. + */ private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory(mContext, alias) + + /** + * File where the wrapped secret key is stored. + */ + private val keyFile = + File(mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), mFilePath) + @get:VisibleForTesting val keyFromCache: SecretKey? get() { @@ -68,28 +101,41 @@ class NewAndroidWrappedKeyProvider( return sKeyCacheMap[mFilePath] } - @VisibleForTesting fun clearKeyFromCache() { sKeyCacheMap.remove(mFilePath) } + /** + * Wipe all the data associated from this key. + */ + @VisibleForTesting + @Throws(ClientException::class) + fun deleteSecretKeyFromStorage() { + AndroidKeyStoreUtil.deleteKey(alias) + FileUtil.deleteFile(keyFile) + sKeyCacheMap.remove(mFilePath) + } + private fun clearCachedKeyIfCantLoadOrFileDoesNotExist() { - val shouldClearCache = !sSkipKeyInvalidationCheck && + val shouldClearCache = !KeyStoreBackedSecretKeyProviderFactory.skipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists()) if (shouldClearCache) { sKeyCacheMap.remove(mFilePath) } } - + /** + * Returns the secret key. If the key is already cached, it returns the cached key. + * If the key is not cached, it tries to read the key from storage. + * If the key does not exist in storage, it generates a new secret key and caches it. + * + * @return SecretKey + * @throws ClientException if there is an error reading or generating the key + */ @get:Throws(ClientException::class) @get:Synchronized override val key: SecretKey - /** - * If key is already generated, that one will be returned. - * Otherwise, generate a new one and return. - */ get() { val methodTag = "$TAG:getKey" @@ -122,6 +168,15 @@ class NewAndroidWrappedKeyProvider( return newKey } + /** + * Generates a new secret key and wraps it using a KeyPair stored in the Android KeyStore. + * If a KeyPair does not exist, it generates a new KeyPair. + * This method will also clear the cached key if it cannot load the key or if the key file does not exist. + * + * @return SecretKey The newly generated secret key. + * @throws ClientException if there is an error generating the key or wrapping it + * + */ @Throws(ClientException::class) fun generateNewSecretKey(): SecretKey { /* @@ -147,20 +202,8 @@ class NewAndroidWrappedKeyProvider( ?: run { Logger.info(methodTag, "No existing keypair found. Generating a new one.") generateKeyPair() - //generateNewKeyPair() } - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - Log.i( - methodTag, - "Selected cipher spec for key wrapping: ${cipherParamsSpec.transformation}"+ - "\n cipherParamsSpec = ${cipherParamsSpec.algorithmParameterSpec}" - ) - val keyWrapped = AndroidKeyStoreUtil.wrap( - newSecretKey, - keyPair, - cipherParamsSpec.transformation, - cipherParamsSpec.algorithmParameterSpec - ) + val keyWrapped = wrapSecretKey(newSecretKey,keyPair) FileUtil.writeDataToFile(keyWrapped, keyFile) return newSecretKey } @@ -191,26 +234,7 @@ class NewAndroidWrappedKeyProvider( clearKeyFromCache() return null } - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - Log.i( - methodTag, - "Selected cipher spec for key unwrapping: ${cipherParamsSpec.transformation}"+ - "\n cipherParamsSpec = ${cipherParamsSpec.algorithmParameterSpec}" - ) - val key = AndroidKeyStoreUtil.unwrap( - wrappedSecretKey, - AES256SecretKeyGenerator.AES_ALGORITHM, - keyPair, - cipherParamsSpec.transformation, - cipherParamsSpec.algorithmParameterSpec - ) - - Logger.info( - methodTag, "Key is loaded with thumbprint: " + - KeyUtil.getKeyThumbPrint(key) - ) - - return key + return unwrapSecretKey(wrappedSecretKey, keyPair) } catch (e: ClientException) { // Reset KeyPair info so that new request will generate correct KeyPairs. // All tokens with previous SecretKey are not possible to decrypt. @@ -223,33 +247,83 @@ class NewAndroidWrappedKeyProvider( } } + private fun wrapSecretKey( + secretKey: SecretKey, + keyPair: KeyPair + ): ByteArray { + val methodTag = "$TAG:wrapSecretKey" + val span = OTelUtility.createSpanFromParent(SpanName.SecretKeyWrapping.name, SpanExtension.current().spanContext) + return try { + SpanExtension.makeCurrentSpan(span).use { _ -> + span.setAttribute(AttributeName.secret_key_wrapping_operation.name, methodTag) + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + span.setAttribute(AttributeName.secret_key_wrapping_cipher.name, cipherParamsSpec.toString()) + Logger.info(methodTag, "Wrapping secret key with cipher spec: $cipherParamsSpec") + val wrappedKey = AndroidKeyStoreUtil.wrap( + secretKey, + keyPair, + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec + ) + span.setStatus(StatusCode.OK) + wrappedKey + } + } catch (exception: Exception) { + Logger.error(methodTag, "Failed to wrap secret key", exception) + span.setStatus(StatusCode.ERROR) + span.recordException(exception) + throw exception + } finally { + span.end() + } + } + private fun unwrapSecretKey( + wrappedSecretKey: ByteArray, + keyPair: KeyPair + ): SecretKey { + val methodTag = "$TAG:unwrapSecretKey" + val span = OTelUtility.createSpanFromParent(SpanName.SecretKeyWrapping.name, SpanExtension.current().spanContext) - - - - - - - + return try { + SpanExtension.makeCurrentSpan(span).use { _ -> + span.setAttribute(AttributeName.secret_key_wrapping_operation.name, methodTag) + val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + span.setAttribute(AttributeName.secret_key_wrapping_cipher.name, cipherParamsSpec.toString()) + Logger.info(methodTag, "Wrapping secret key with cipher spec: $cipherParamsSpec") + val key = AndroidKeyStoreUtil.unwrap( + wrappedSecretKey, + AES256SecretKeyGenerator.AES_ALGORITHM, + keyPair, + cipherParamsSpec.transformation, + cipherParamsSpec.algorithmParameterSpec + ) + span.setStatus(StatusCode.OK) + key + } + } catch (exception: Exception) { + Logger.error(methodTag, "Failed to wrap secret key", exception) + span.setStatus(StatusCode.ERROR) + span.recordException(exception) + throw exception + } finally { + span.end() + } + } /** - * Wipe all the data associated from this key. + * Selects the most compatible cipher specification for the given key pair. + * + * Matches key pair's supported encryption paddings with available cipher specs, + * prioritizing more secure options first. Falls back to PKCS1 if no match found. + * + * @param keyPair The key pair to find compatible cipher spec for + * @return Compatible [CipherSpec] or PKCS1 fallback */ - // VisibleForTesting - @Throws(ClientException::class) - fun deleteSecretKeyFromStorage() { - AndroidKeyStoreUtil.deleteKey(alias) - FileUtil.deleteFile(keyFile) - sKeyCacheMap.remove(mFilePath) - } - - - private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { val methodTag = "$TAG:selectCompatibleCipherSpec" - val supportedPaddings = AndroidKeyStoreUtil.getEncryptionPaddings(keyPair) + val supportedPaddings = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(keyPair) val availableCipherSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() Logger.verbose( methodTag, @@ -259,6 +333,7 @@ class NewAndroidWrappedKeyProvider( for (cipherSpec in availableCipherSpecs) { for (padding in supportedPaddings) { if (cipherSpec.padding.contains(padding, ignoreCase = true)) { + Logger.info(methodTag, "Selected cipher spec: $cipherSpec") return cipherSpec } } @@ -268,92 +343,116 @@ class NewAndroidWrappedKeyProvider( return cryptoParameterSpecFactory.pkcs1CipherSpec } - + /** + * Generates a new RSA key pair using prioritized specifications with fallback support. + * + * Attempts key generation with multiple specs in order of preference (modern to legacy). + * Includes comprehensive error handling and telemetry tracking. + * + * @return Generated [KeyPair] from Android KeyStore + * @throws ClientException if all key generation attempts fail + */ @Throws(ClientException::class) private fun generateKeyPair(): KeyPair { val methodTag = "$TAG:generateKeyPair" val span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name, SpanExtension.current().spanContext) val failures = mutableListOf() - val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() - - try { + return try { SpanExtension.makeCurrentSpan(span).use { _ -> - for (spec in specs) { - try { - val keypairGenStartTime = System.currentTimeMillis() - val keyPair = AndroidKeyStoreUtil.generateKeyPair( - spec.algorithm, - spec.algorithmParameterSpec - ) - val elapsedTime = System.currentTimeMillis() - keypairGenStartTime - SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) - span.setStatus(StatusCode.OK) - Log.i(methodTag, "Key pair generated successfully with spec: $spec ") - return keyPair - } catch (throwable: Throwable) { - Logger.warn(methodTag, "Failed to generate key pair with spec: $spec") - failures.add(throwable) - } - } - - // If we reach here, all attempts have failed - failures.forEach { exception -> - Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) + val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() + validateSpecsAvailable(specs) + + for ((index, spec) in specs.withIndex()) { + Logger.verbose(methodTag, "Attempting key generation with spec ${index + 1}: $spec") + attemptKeyGeneration(spec) + .onSuccess { keyPair -> + Logger.info(methodTag, "Key pair generated successfully with spec: $spec") + span.setAttribute(AttributeName.key_pair_gen_successful_method.name, spec.toString()) + span.setStatus(StatusCode.OK) + return@use keyPair + } + .onFailure { throwable -> + Logger.warn(methodTag, "Failed to generate key pair with spec: $spec, error: ${throwable.message}") + failures.add(throwable) + } } - val finalError = failures.lastOrNull() ?: ClientException( - ClientException.UNKNOWN_CRYPTO_ERROR, - "Key pair generation failed after trying all available specs." - ) - span.setStatus(StatusCode.ERROR) - span.recordException(finalError) - throw ExceptionAdapter.clientExceptionFromException(finalError) + handleAllFailures(failures) } } finally { span.end() } } + /** + * Validates that key generation specifications are available for use. + * + * Ensures at least one specification exists before attempting key generation. + * Records telemetry and throws exception if no specs are available. + * + * @param specs List of key generation specifications to validate + * @throws ClientException if specs list is empty + */ + @Throws(ClientException::class) + private fun validateSpecsAvailable(specs: List) { + if (specs.isEmpty()) { + val error = ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "No key generation specifications available for generating key pair." + ) + SpanExtension.current().setStatus(StatusCode.ERROR) + SpanExtension.current().recordException(error) + throw ExceptionAdapter.clientExceptionFromException(error) + } + } - private val keyFile: File - /** - * Get the file that stores the wrapped key. - */ - get() = File( - mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), - mFilePath - ) - - companion object { - /** - * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in - * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here - * probably doing PKCS7. We decide to go with Java default string. - */ - const val AES_CBC_PKCS5_PADDING_TRANSFORMATION: String = "AES/CBC/PKCS5Padding" - - - private val TAG = NewAndroidWrappedKeyProvider::class.java.simpleName + "#" - - /** - * Should KeyStore and key file check for validity before every key load be skipped. - */ - @SuppressFBWarnings("MS_SHOULD_BE_FINAL") - var sSkipKeyInvalidationCheck: Boolean = false - - - /** - * Indicate that token item is encrypted with the key loaded in this class. - */ - const val KEY_TYPE_IDENTIFIER: String = "A001" + /** + * Attempts key pair generation with a single specification and measures performance. + * + * Wraps key generation in Result for safe exception handling and tracks + * generation time for telemetry purposes. + * + * @param spec The key generation specification to attempt + * @return [Result] containing generated KeyPair or captured exception + */ + private fun attemptKeyGeneration(spec: IKeyGenSpec): Result { + return runCatching { + val startTime = System.currentTimeMillis() + val keyPair = AndroidKeyStoreUtil.generateKeyPair( + spec.algorithm, + spec.algorithmParameterSpec + ) + val elapsedTime = System.currentTimeMillis() - startTime + SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) + keyPair + } + } + /** + * Handles all key generation failures and throws a ClientException. + * + * Logs each failure, records telemetry data, and throws an exception based on the last failure. + * + * @param failures List of exceptions encountered during key generation attempts + * @throws ClientException Always throws after processing all failures + */ + private fun handleAllFailures(failures: List): Nothing { + val methodTag = "$TAG:handleAllFailures" + require(failures.isNotEmpty()) { + "No failures encountered, but no key pair generated. This should not happen." + } + val errorMessages = failures.joinToString(separator = "; ") { exception -> + "${exception.javaClass.simpleName}: ${exception.message ?: "Unknown error"}" + } - // Exposed for testing only. - /* package */ - const val KEY_FILE_SIZE: Int = 1024 + failures.forEach { exception -> + Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) + } + SpanExtension.current().setAttribute(AttributeName.keypair_gen_exception.name, errorMessages) - /** - * SecretKey cache. Maps wrapped secret key file path to the SecretKey. - */ - private val sKeyCacheMap: ConcurrentMap = ConcurrentHashMap() + val finalError = failures.last() + SpanExtension.current().setStatus(StatusCode.ERROR) + SpanExtension.current().recordException(finalError) + throw ExceptionAdapter.clientExceptionFromException(finalError) } -} \ No newline at end of file +} + diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt index e79fb40727..622de6a877 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt @@ -26,22 +26,62 @@ import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager +/** + * Factory object for creating instances of [ISecretKeyProvider] implementations. + * + * This factory determines which type of secret key provider to create based on a feature flag. + * The factory supports: + * - **KeyStoreBackedSecretKeyProvider**: Modern implementation that supports enhanced security encryption paddings. + * - **AndroidWrappedKeyProvider**: Legacy fallback implementation for backward compatibility + * + */ +object KeyStoreBackedSecretKeyProviderFactory { -object AndroidWrappedKeyProviderFactory { + /** + * Controls whether key invalidation checks should be skipped during key loading operations. + * + * When set to `true`, the key providers will skip validation checks for KeyStore and key file + * integrity before every key load operation. This can improve performance but may reduce + * security guarantees. + * + * Default: `false` (key invalidation checks are performed) + */ + @JvmField var skipKeyInvalidationCheck: Boolean = false - fun createWrappedKeyProvider( + /** + * Creates an appropriate [ISecretKeyProvider] instance based on current feature flag settings. + * + * This method selects between different key provider implementations based on the + * [CommonFlight.ENABLE_KEYSTORE_BACKED_SECRET_KEY_PROVIDER] feature flag: + * + * @param keyIdentifier A unique identifier for the key, typically used as the Android KeyStore alias. + * This should be unique within the application to avoid key collisions. + * @param fileName The filename where the wrapped key data will be stored. This file will be + * created in the application's private directory and should be unique per key. + * @param context The Android application context, used for accessing the KeyStore and + * creating private storage directories. + * @return An [ISecretKeyProvider] instance configured with the specified parameters. + * + * @throws IllegalArgumentException if any of the parameters are invalid + * @throws SecurityException if the application lacks necessary permissions for KeyStore access + * + * @see ISecretKeyProvider.key + * @see KeyStoreBackedSecretKeyProvider + * @see AndroidWrappedKeyProvider + */ + fun create( keyIdentifier: String, fileName: String, context: android.content.Context ): ISecretKeyProvider { - val enableOaepAndroidWrappedKeyLoader = + val enableKeyStoreBackedSecretKeyProvider = CommonFlightsManager .getFlightsProvider() - .isFlightEnabled(CommonFlight.ENABLE_OAEP_ANDROID_WRAPPED_KEY_LOADER) + .isFlightEnabled(CommonFlight.ENABLE_KEYSTORE_BACKED_SECRET_KEY_PROVIDER) - return if (enableOaepAndroidWrappedKeyLoader) { - NewAndroidWrappedKeyProvider( + return if (enableKeyStoreBackedSecretKeyProvider) { + KeyStoreBackedSecretKeyProvider( keyIdentifier, fileName, context diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index 6c84a33118..c7b9dea79b 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -483,13 +483,17 @@ public static synchronized SecretKey unwrap(final byte[] wrappedKeyBlob, } /** - * Returns a list of encryption paddings supported by the key pair. + * Returns encryption paddings supported by a KeyStore key pair. + *

+ * Extracts supported padding schemes from the key's metadata on API 23+. + * Strips "Padding" suffix from padding names for consistent formatting. * - * @param keyPair The key pair for which to get the encryption paddings. - * @return A list of encryption paddings supported by the key pair. + * @param keyPair The key pair to query for supported encryption paddings + * @return List of supported padding names (e.g., "PKCS1", "OAEP"), + * or empty list on API < 23 or if retrieval fails */ - public static synchronized List getEncryptionPaddings(@NonNull final KeyPair keyPair) { - final String methodTag = TAG + ":getEncryptionPaddings"; + public static synchronized List getKeyPairEncryptionPaddings(@NonNull final KeyPair keyPair) { + final String methodTag = TAG + ":getKeyPairEncryptionPaddings"; try { final PrivateKey privateKey = keyPair.getPrivate(); final KeyFactory keyFactory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEY_STORE_TYPE); @@ -504,10 +508,10 @@ public static synchronized List getEncryptionPaddings(@NonNull final Key Logger.info(methodTag, "Supported encryption paddings: " + encryptionPaddings); return encryptionPaddings; } else { - Logger.warn(methodTag, "getKeyInfo is not supported on this Android version"); + Logger.warn(methodTag, "KeyInfo not available on API < 23"); } } catch (final Exception e) { - Logger.warn(methodTag, "Failed to get KeyInfo for alias, swallowing exception"); + Logger.warn(methodTag, "Failed to retrieve key padding information" + ": " + e.getMessage()); } return Collections.emptyList(); } diff --git a/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java b/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java index cfe0bb19e5..bc4117628f 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java +++ b/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java @@ -347,7 +347,7 @@ public void testUnwrap_KeyStoreUnavailable_ThrowsClientException() throws Except // Real implementation tests for getEncryptionPaddings @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_ReturnsProcessedPaddings() throws Exception { + public void testGetKeyPairEncryptionPaddings_ModernAPI_ReturnsProcessedPaddings() throws Exception { // Arrange String[] paddingsFromKeyInfo = {"RSA_PKCS1Padding", "RSA_OAEPPadding"}; @@ -359,7 +359,7 @@ public void testGetEncryptionPaddings_ModernAPI_ReturnsProcessedPaddings() throw when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); // Act - Call the REAL method, not mocked - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert - Verify the actual processing logic worked assertNotNull(result); @@ -371,7 +371,7 @@ public void testGetEncryptionPaddings_ModernAPI_ReturnsProcessedPaddings() throw @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_SinglePadding() throws Exception { + public void testGetKeyPairEncryptionPaddings_ModernAPI_SinglePadding() throws Exception { // Arrange String[] paddingsFromKeyInfo = {"RSA_PKCS1Padding"}; @@ -383,7 +383,7 @@ public void testGetEncryptionPaddings_ModernAPI_SinglePadding() throws Exception when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert assertNotNull(result); @@ -394,7 +394,7 @@ public void testGetEncryptionPaddings_ModernAPI_SinglePadding() throws Exception @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_EmptyPaddingsArray() throws Exception { + public void testGetKeyPairEncryptionPaddings_ModernAPI_EmptyPaddingsArray() throws Exception { // Arrange String[] paddingsFromKeyInfo = {}; @@ -406,7 +406,7 @@ public void testGetEncryptionPaddings_ModernAPI_EmptyPaddingsArray() throws Exce when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert assertNotNull(result); @@ -416,14 +416,14 @@ public void testGetEncryptionPaddings_ModernAPI_EmptyPaddingsArray() throws Exce @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_KeyFactoryException_ReturnsEmptyList() { + public void testGetKeyPairEncryptionPaddings_ModernAPI_KeyFactoryException_ReturnsEmptyList() { // Arrange try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) .thenThrow(new NoSuchAlgorithmException("Algorithm not found")); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert assertNotNull(result); @@ -433,7 +433,7 @@ public void testGetEncryptionPaddings_ModernAPI_KeyFactoryException_ReturnsEmpty @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_KeySpecException_ReturnsEmptyList() throws Exception { + public void testGetKeyPairEncryptionPaddings_ModernAPI_KeySpecException_ReturnsEmptyList() throws Exception { // Arrange try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) @@ -442,7 +442,7 @@ public void testGetEncryptionPaddings_ModernAPI_KeySpecException_ReturnsEmptyLis .thenThrow(new InvalidKeySpecException("Invalid key spec")); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert assertNotNull(result); @@ -452,14 +452,14 @@ public void testGetEncryptionPaddings_ModernAPI_KeySpecException_ReturnsEmptyLis @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_NoSuchProviderException_ReturnsEmptyList() { + public void testGetKeyPairEncryptionPaddings_ModernAPI_NoSuchProviderException_ReturnsEmptyList() { // Arrange try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) .thenThrow(new NoSuchProviderException("Provider not found")); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert assertNotNull(result); @@ -469,7 +469,7 @@ public void testGetEncryptionPaddings_ModernAPI_NoSuchProviderException_ReturnsE @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_RuntimeException_ReturnsEmptyList() throws Exception { + public void testGetKeyPairEncryptionPaddings_ModernAPI_RuntimeException_ReturnsEmptyList() throws Exception { // Arrange try (MockedStatic keyFactoryMock = mockStatic(KeyFactory.class)) { keyFactoryMock.when(() -> KeyFactory.getInstance(RSA_ALGORITHM, ANDROID_KEYSTORE_PROVIDER)) @@ -478,7 +478,7 @@ public void testGetEncryptionPaddings_ModernAPI_RuntimeException_ReturnsEmptyLis .thenThrow(new RuntimeException("Unexpected runtime error")); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert assertNotNull(result); @@ -488,9 +488,9 @@ public void testGetEncryptionPaddings_ModernAPI_RuntimeException_ReturnsEmptyLis @Test @Config(sdk = Build.VERSION_CODES.LOLLIPOP) // API 21, before M - public void testGetEncryptionPaddings_LegacyAPI_ReturnsEmptyList() { + public void testGetKeyPairEncryptionPaddings_LegacyAPI_ReturnsEmptyList() { // Act - Call the REAL method on legacy API - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert - Should return empty list because API < 23 assertNotNull(result); @@ -499,9 +499,9 @@ public void testGetEncryptionPaddings_LegacyAPI_ReturnsEmptyList() { @Test @Config(sdk = Build.VERSION_CODES.KITKAT) // API 19, before M - public void testGetEncryptionPaddings_VeryLegacyAPI_ReturnsEmptyList() { + public void testGetKeyPairEncryptionPaddings_VeryLegacyAPI_ReturnsEmptyList() { // Act - Call the REAL method on very legacy API - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert - Should return empty list because API < 23 assertNotNull(result); @@ -510,7 +510,7 @@ public void testGetEncryptionPaddings_VeryLegacyAPI_ReturnsEmptyList() { @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_PaddingsWithoutSuffix_ReturnedAsIs() throws Exception { + public void testGetKeyPairEncryptionPaddings_ModernAPI_PaddingsWithoutSuffix_ReturnedAsIs() throws Exception { // Arrange - Test case where paddings don't have "Padding" suffix String[] paddingsFromKeyInfo = {"RSA_PKCS1", "RSA_OAEP", "SomethingElse"}; @@ -522,7 +522,7 @@ public void testGetEncryptionPaddings_ModernAPI_PaddingsWithoutSuffix_ReturnedAs when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert - Should return as-is since no "Padding" suffix to strip assertNotNull(result); @@ -535,7 +535,7 @@ public void testGetEncryptionPaddings_ModernAPI_PaddingsWithoutSuffix_ReturnedAs @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetEncryptionPaddings_ModernAPI_MixedPaddingFormats() throws Exception { + public void testGetKeyPairEncryptionPaddings_ModernAPI_MixedPaddingFormats() throws Exception { // Arrange - Test mix of paddings with and without "Padding" suffix String[] paddingsFromKeyInfo = {"RSA_PKCS1Padding", "RSA_OAEP", "AESPadding"}; @@ -547,7 +547,7 @@ public void testGetEncryptionPaddings_ModernAPI_MixedPaddingFormats() throws Exc when(mockKeyInfo.getEncryptionPaddings()).thenReturn(paddingsFromKeyInfo); // Act - List result = AndroidKeyStoreUtil.getEncryptionPaddings(mockKeyPair); + List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); // Assert - Should strip "Padding" where present, leave others as-is assertNotNull(result); diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index c3150e30e5..bb2024c9ca 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -129,7 +129,7 @@ public enum CommonFlight implements IFlightConfig { /** * Flight to enable the OAEP Android Wrapped Key Loader. */ - ENABLE_OAEP_ANDROID_WRAPPED_KEY_LOADER("EnableOaepAndroidWrappedKeyLoader", true), + ENABLE_KEYSTORE_BACKED_SECRET_KEY_PROVIDER("EnableKeyStoreBackedSecretKeyProvider", false), /** * Flight to enable handling the UI in edge to edge mode diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index cae7ccb699..da097c5a15 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -366,9 +366,14 @@ public enum AttributeName { is_switch_browser_resume_handled, /** - * Records the transformation used to unwrap a key pair. + * Records the Cipher used to encrypt/decrypt the keypair. */ - key_pair_unwrap_transformation, + secret_key_wrapping_cipher, + + /** + * Records the operation used to wrap/unwrap the secret key. + */ + secret_key_wrapping_operation, /** * Records if the request is a webcp authorize request. diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java index 3536e9c5b5..4fdcbd3b9a 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/SpanName.java @@ -64,7 +64,7 @@ public enum SpanName { ProcessCrossCloudRedirect, SwitchBrowserResume, SwitchBrowserProcess, - KeyPairUnWrap, + SecretKeyWrapping, WrappedKeyAlgorithmIdentifier, ProcessWebCpRedirects } From 6cf803999b60d75a5f4ccb1d2934992a91dba21f Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 14 Jul 2025 18:19:58 -0700 Subject: [PATCH 22/51] feat: Update CryptoParameterSpecFactory with lazy initialization and add Kotlin test cases --- .../crypto/CryptoParameterSpecFactory.kt | 10 +- .../CryptoParameterSpecFactoryTest.java | 215 ---------------- .../crypto/CryptoParameterSpecFactoryTest.kt | 239 ++++++++++++++++++ 3 files changed, 245 insertions(+), 219 deletions(-) delete mode 100644 common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java create mode 100644 common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index ccfbaf32b5..2f682cedd4 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -132,8 +132,8 @@ class CryptoParameterSpecFactory( ) // Key generation parameter specifications - @RequiresApi(Build.VERSION_CODES.P) - private val keyGenParamSpecWithPurposeWrapKey = + @delegate:RequiresApi(Build.VERSION_CODES.P) + private val keyGenParamSpecWithPurposeWrapKey by lazy { KeyGenSpec( keyAlias = keyAlias, purposes = KeyProperties.PURPOSE_ENCRYPT or @@ -148,9 +148,10 @@ class CryptoParameterSpecFactory( encryptionPaddings = getEncryptionPaddingsForKeyGen(), algorithm = RSA_ALGORITHM ) + } - @RequiresApi(Build.VERSION_CODES.M) - private val keyGenParamSpecWithoutPurposeWrapKey = + @delegate:RequiresApi(Build.VERSION_CODES.M) + private val keyGenParamSpecWithoutPurposeWrapKey by lazy { KeyGenSpec( keyAlias = keyAlias, purposes = KeyProperties.PURPOSE_ENCRYPT or @@ -164,6 +165,7 @@ class CryptoParameterSpecFactory( encryptionPaddings = getEncryptionPaddingsForKeyGen(), algorithm = RSA_ALGORITHM ) + } private val keyGenParamSpecLegacy = LegacyKeyGenSpec( context = context, diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java deleted file mode 100644 index f2be7a0b7e..0000000000 --- a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.java +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.when; - -import android.content.Context; -import android.os.Build; -import android.security.keystore.KeyProperties; - -import com.microsoft.identity.common.java.flighting.CommonFlight; -import com.microsoft.identity.common.java.flighting.IFlightsProvider; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import java.util.List; - -/** - * Unit tests for {@link CryptoParameterSpecFactory} - */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = {Build.VERSION_CODES.P}) // Targeting Android 9.0 (API 28) for the tests -public class CryptoParameterSpecFactoryTest { - - private static final String TEST_KEY_ALIAS = "test_key_alias"; - - @Mock - private Context mockContext; - - private IFlightsProvider mockFlightsProvider; - - private CryptoParameterSpecFactory cryptoParameterSpecFactory; - - - @Before - public void setUp() { - // Setup mock flights provider - mockFlightsProvider = Mockito.mock(IFlightsProvider.class); - mockContext = Mockito.mock(Context.class); - } - - - @Test - public void testGetPrioritizedCipherParameterSpec() { - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - final List specs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs(); - - // Verify we have 2 specs (OAEP and PKCS1) in that order - assertEquals(2, specs.size()); - assertEquals("RSA/NONE/OAEPwithSHA-256andMGF1Padding", specs.get(0).getTransformation()); - assertEquals("RSA/ECB/PKCS1Padding", specs.get(1).getTransformation()); - } - - @Test - public void testGetPrioritizedKeyGenParameterSpecs_AllFlagsEnabled() { - // When all flags are enabled - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) - .thenReturn(true); - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) - .thenReturn(true); - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) - .thenReturn(true); - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - - // Verify we have 3 specs in the right order - assertEquals(3, specs.size()); - assertEquals("modern_spec_with_wrap_key", specs.get(0).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, specs.get(0).getEncryptionPadding()); - - assertEquals("modern_spec_without_wrap_key", specs.get(1).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, specs.get(1).getEncryptionPadding()); - - assertEquals("legacy_key_gen_spec", specs.get(2).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(2).getEncryptionPadding()); - - } - - @Test - public void testGetPrioritizedKeyGenParameterSpecs_WithoutPurposeWrapKeyOnly() { - // When only WITHOUT_PURPOSE_WRAP_KEY flag is enabled - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) - .thenReturn(true); - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - - // Verify we have 2 specs in the right order - assertEquals(2, specs.size()); - assertEquals("modern_spec_without_wrap_key", specs.get(0).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(0).getEncryptionPadding()); - - assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(1).getEncryptionPadding()); - } - - @Test - public void testGetPrioritizedKeyGenParameterSpecs_WithPurposeWrapKeyOnly() { - // When only WITHOUT_PURPOSE_WRAP_KEY flag is enabled - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) - .thenReturn(true); - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - - // Verify we have 2 specs in the right order - assertEquals(2, specs.size()); - assertEquals("modern_spec_with_wrap_key", specs.get(0).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(0).getEncryptionPadding()); - - assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(1).getEncryptionPadding()); - } - - @Test - public void testGetPrioritizedKeyGenParameterSpecs_NoFlagsEnabled() { - // When no flags are enabled - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - final List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - - // Verify we have only the legacy spec - assertEquals(1, specs.size()); - assertEquals("legacy_key_gen_spec", specs.get(0).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(0).getEncryptionPadding()); - } - - - @Test - @Config(sdk = Build.VERSION_CODES.M) // API 23 - public void testGetPrioritizedKeyGenParameterSpecs_API23_WithFlags() { - // Test on API 23 (M) with flags enabled - // Should include modern spec without wrap key but not the one with wrap key (requires API 28) - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) - .thenReturn(true); - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) - .thenReturn(true); - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) - .thenReturn(true); - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - - // Verify we have 2 specs in the right order (no PURPOSE_WRAP_KEY since it needs API 28) - assertEquals(2, specs.size()); - assertEquals("modern_spec_without_wrap_key", specs.get(0).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, specs.get(0).getEncryptionPadding()); - - assertEquals("legacy_key_gen_spec", specs.get(1).getDescription()); - assertEquals(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, specs.get(1).getEncryptionPadding()); - } - - @Test - @Config(sdk = Build.VERSION_CODES.LOLLIPOP_MR1) // API 22, before M - public void testGetPrioritizedKeyGenParameterSpecs_LegacyAPI() { - // Test on pre-M API where only legacy spec should be available - // Should include modern spec without wrap key but not the one with wrap key (requires API 28) - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) - .thenReturn(true); - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) - .thenReturn(true); - when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) - .thenReturn(true); - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = new CryptoParameterSpecFactory(mockContext, TEST_KEY_ALIAS, mockFlightsProvider); - - // Get the prioritized specs - List specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs(); - - // Verify we have only the legacy spec regardless of flags - assertEquals(1, specs.size()); - assertEquals("legacy_key_gen_spec", specs.get(0).getDescription()); - } -} diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt new file mode 100644 index 0000000000..d6486eebed --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyProperties +import com.microsoft.identity.common.java.flighting.CommonFlight +import com.microsoft.identity.common.java.flighting.IFlightsProvider +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [CryptoParameterSpecFactory] + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) // Targeting Android 9.0 (API 28) for the tests +class CryptoParameterSpecFactoryTest { + @Mock + private var mockContext: Context? = null + + private var mockFlightsProvider: IFlightsProvider? = null + + private var cryptoParameterSpecFactory: CryptoParameterSpecFactory? = null + + + @Before + fun setUp() { + // Setup mock flights provider + mockFlightsProvider = Mockito.mock(IFlightsProvider::class.java) + mockContext = Mockito.mock(Context::class.java) + } + + + @Test + fun testGetPrioritizedCipherParameterSpec() { + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = CryptoParameterSpecFactory( + mockContext!!, TEST_KEY_ALIAS, + mockFlightsProvider!! + ) + + // Get the prioritized specs + val specs = cryptoParameterSpecFactory!!.getPrioritizedCipherParameterSpecs() + + // Verify we have 2 specs (OAEP and PKCS1) in that order + Assert.assertEquals(2, specs.size) + Assert.assertEquals("RSA/NONE/OAEPwithSHA-256andMGF1Padding", specs[0].transformation) + Assert.assertEquals("RSA/ECB/PKCS1Padding", specs[1].transformation) + } + + @Test + fun testGetPrioritizedKeyGenParameterSpecs_AllFlagsEnabled() { + // When all flags are enabled + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(true) + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = CryptoParameterSpecFactory( + mockContext!!, TEST_KEY_ALIAS, + mockFlightsProvider!! + ) + + // Get the prioritized specs + val specs = cryptoParameterSpecFactory!!.getPrioritizedKeyGenParameterSpecs() + + // Verify we have 3 specs in the right order + Assert.assertEquals(3, specs.size.toLong()) + Assert.assertEquals("modern_spec_with_wrap_key", specs[0].description) + Assert.assertEquals( + listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, KeyProperties.ENCRYPTION_PADDING_RSA_OAEP), + specs[0].encryptionPaddings + ) + + Assert.assertEquals("modern_spec_without_wrap_key", specs[1].description) + Assert.assertEquals( + listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, KeyProperties.ENCRYPTION_PADDING_RSA_OAEP), + specs[1].encryptionPaddings + ) + + Assert.assertEquals("legacy_key_gen_spec", specs[2].description) + Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[2].encryptionPaddings) + } + + @Test + fun testGetPrioritizedKeyGenParameterSpecs_WithoutPurposeWrapKeyOnly() { + // When only WITHOUT_PURPOSE_WRAP_KEY flag is enabled + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true) + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = CryptoParameterSpecFactory( + mockContext!!, TEST_KEY_ALIAS, + mockFlightsProvider!! + ) + + // Get the prioritized specs + val specs = cryptoParameterSpecFactory!!.getPrioritizedKeyGenParameterSpecs() + + // Verify we have 2 specs in the right order + Assert.assertEquals(2, specs.size.toLong()) + Assert.assertEquals("modern_spec_without_wrap_key", specs[0].description) + Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[0].encryptionPaddings) + + Assert.assertEquals("legacy_key_gen_spec", specs[1].description) + Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[1].encryptionPaddings) + } + + @Test + fun testGetPrioritizedKeyGenParameterSpecs_WithPurposeWrapKeyOnly() { + // When only WITHOUT_PURPOSE_WRAP_KEY flag is enabled + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true) + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = CryptoParameterSpecFactory( + mockContext!!, TEST_KEY_ALIAS, + mockFlightsProvider!! + ) + + // Get the prioritized specs + val specs = cryptoParameterSpecFactory!!.getPrioritizedKeyGenParameterSpecs() + + // Verify we have 2 specs in the right order + Assert.assertEquals(2, specs.size.toLong()) + Assert.assertEquals("modern_spec_with_wrap_key", specs[0].description) + Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[0].encryptionPaddings) + + Assert.assertEquals("legacy_key_gen_spec", specs[1].description) + Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[1].encryptionPaddings) + } + + @Test + fun testGetPrioritizedKeyGenParameterSpecs_NoFlagsEnabled() { + // When no flags are enabled + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = CryptoParameterSpecFactory( + mockContext!!, TEST_KEY_ALIAS, + mockFlightsProvider!! + ) + + // Get the prioritized specs + val specs = cryptoParameterSpecFactory!!.getPrioritizedKeyGenParameterSpecs() + + // Verify we have only the legacy spec + Assert.assertEquals(1, specs.size.toLong()) + Assert.assertEquals("legacy_key_gen_spec", specs[0].description) + Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[0].encryptionPaddings) + } + + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) // API 23 + fun testGetPrioritizedKeyGenParameterSpecs_API23_WithFlags() { + // Test on API 23 (M) with flags enabled + // Should include modern spec without wrap key but not the one with wrap key (requires API 28) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(true) + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = CryptoParameterSpecFactory( + mockContext!!, TEST_KEY_ALIAS, + mockFlightsProvider!! + ) + + // Get the prioritized specs + val specs = cryptoParameterSpecFactory!!.getPrioritizedKeyGenParameterSpecs() + + // Verify we have 2 specs in the right order (no PURPOSE_WRAP_KEY since it needs API 28) + Assert.assertEquals(2, specs.size.toLong()) + Assert.assertEquals("modern_spec_without_wrap_key", specs[0].description) + Assert.assertEquals( + listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, KeyProperties.ENCRYPTION_PADDING_RSA_OAEP), + specs[0].encryptionPaddings + ) + + Assert.assertEquals("legacy_key_gen_spec", specs[1].description) + Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[1].encryptionPaddings) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP_MR1]) // API 22, before M + fun testGetPrioritizedKeyGenParameterSpecs_LegacyAPI() { + // Test on pre-M API where only legacy spec should be available + // Should include modern spec without wrap key but not the one with wrap key (requires API 28) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true) + Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(true) + // Re-create the factory with the updated flags + cryptoParameterSpecFactory = CryptoParameterSpecFactory( + mockContext!!, TEST_KEY_ALIAS, + mockFlightsProvider!! + ) + + // Get the prioritized specs + val specs = cryptoParameterSpecFactory!!.getPrioritizedKeyGenParameterSpecs() + + // Verify we have only the legacy spec regardless of flags + Assert.assertEquals(1, specs.size.toLong()) + Assert.assertEquals("legacy_key_gen_spec", specs[0].description) + } + + companion object { + private const val TEST_KEY_ALIAS = "test_key_alias" + } +} From 41390b8954fa8e70b71a2f210ae89997e34ee33f Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 15 Jul 2025 13:43:34 -0700 Subject: [PATCH 23/51] feat: Update AndroidKeyStoreUtilTest to handle null parameters in wrap and unwrap methods --- .../crypto/AndroidWrappedKeyProviderTest.java | 75 ------------------- .../util/AndroidKeyStoreUtilTest.java | 15 ++-- 2 files changed, 10 insertions(+), 80 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index dadd805494..5b7a33423e 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -26,14 +26,11 @@ import android.content.Context; import android.security.KeyPairGeneratorSpec; -import android.security.keystore.KeyGenParameterSpec; -import android.security.keystore.KeyProperties; import androidx.test.core.app.ApplicationProvider; import com.microsoft.identity.common.adal.internal.AuthenticationSettings; import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; -import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.util.FileUtil; @@ -44,23 +41,12 @@ import java.io.File; import java.math.BigInteger; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; import java.security.spec.AlgorithmParameterSpec; -import java.security.spec.MGF1ParameterSpec; import java.util.Arrays; import java.util.Date; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; -import javax.crypto.spec.OAEPParameterSpec; -import javax.crypto.spec.PSource; import javax.security.auth.x500.X500Principal; public class AndroidWrappedKeyProviderTest { @@ -275,46 +261,6 @@ public void testLoadDeletedKeyFile() throws ClientException { Assert.assertNull(key); } - @Test - public void test1() throws ClientException, NoSuchAlgorithmException, NoSuchProviderException, - NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore"); - - keyPairGenerator.initialize(new KeyGenParameterSpec.Builder( - "my_rsa_key_alias", // alias for keystore - KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) - .setKeySize(2048) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA1) // allow both digests - .build()); - - KeyPair keyPair = keyPairGenerator.generateKeyPair(); - - // Assume AES key was created elsewhere - SecretKey aesKey = AES256SecretKeyGenerator.INSTANCE.generateRandomKey(); - - // Use OAEPParameterSpec with SHA-256 as main digest but SHA-1 for MGF1 - OAEPParameterSpec oaepParams = new OAEPParameterSpec( - "SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT); - - Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding"); - cipher.init(Cipher.WRAP_MODE, keyPair.getPublic(), oaepParams); - - byte[] wrappedKey = cipher.wrap(aesKey); - - Cipher cipher2 = Cipher.getInstance("RSA/ECB/OAEPPadding"); - cipher2.init(Cipher.UNWRAP_MODE, keyPair.getPrivate(), oaepParams); - - // Unwrap the key back into SecretKey object - SecretKey unwrappedKey = (SecretKey) cipher2.unwrap(wrappedKey, "AES", Cipher.SECRET_KEY); - - // Verify the keys match - Assert.assertEquals(aesKey.getAlgorithm(), unwrappedKey.getAlgorithm()); - Assert.assertArrayEquals(aesKey.getEncoded(), unwrappedKey.getEncoded()); - } - - private KeyStoreBackedSecretKeyProvider initkeyProviderWithKeyEntry() throws ClientException { final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); final SecretKey key = keyProvider.getKey(); @@ -322,25 +268,4 @@ private KeyStoreBackedSecretKeyProvider initkeyProviderWithKeyEntry() throws Cli Assert.assertNotNull(keyProvider.getKeyFromCache()); return keyProvider; } - - /** - * Helper method to generate a random key using KeyStoreBackedSecretKeyProvider. - * This method is used to substitute the call to keyLoader.generateRandomKey() in tests. - * - * @param keyLoader The KeyStoreBackedSecretKeyProvider instance to use - * @return The generated SecretKey - * @throws ClientException if key generation fails - */ - private SecretKey generateRandomKey(KeyStoreBackedSecretKeyProvider keyLoader) throws ClientException { - // Get the key will generate a new one if it doesn't exist - SecretKey key = keyLoader.getKey(); - - // Clear the cache to ensure it's regenerated next time - keyLoader.clearKeyFromCache(); - - // Generate a new key by getting the key again which will create a new one - key = keyLoader.getKey(); - - return key; - } } diff --git a/common/src/androidTest/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java b/common/src/androidTest/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java index 18c0e125a3..d123287639 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java @@ -70,7 +70,8 @@ public void testUnwrap_Success() throws ClientException { final byte[] wrapped = AndroidKeyStoreUtil.wrap( mSecretKey, mKeyPair, - TEST_WRAP_ALGORITHM + TEST_WRAP_ALGORITHM, + null ); // Test successful unwrap @@ -78,7 +79,8 @@ public void testUnwrap_Success() throws ClientException { wrapped, TEST_KEY_ALGORITHM, mKeyPair, - TEST_WRAP_ALGORITHM + TEST_WRAP_ALGORITHM, + null ); assertNotNull(unwrapped); @@ -91,14 +93,16 @@ public void testUnwrap_NoSuchAlgorithm() { final byte[] wrapped = AndroidKeyStoreUtil.wrap( mSecretKey, mKeyPair, - TEST_WRAP_ALGORITHM + TEST_WRAP_ALGORITHM, + null ); final SecretKey secretKey = AndroidKeyStoreUtil.unwrap( wrapped, TEST_KEY_ALGORITHM, mKeyPair, - "NoAlg" + "NoAlg", + null ); fail("Should have thrown ClientException"); } catch (final ClientException e) { @@ -113,7 +117,8 @@ public void testUnwrap_InvalidKey() { TEST_WRAPPED_KEY_BYTES, TEST_KEY_ALGORITHM, mKeyPair, - TEST_WRAP_ALGORITHM + TEST_WRAP_ALGORITHM, + null ); fail("Should have thrown ClientException"); } catch (final ClientException e) { From 54ad32bbe82a9327fcd612fdf9b04fa92a4fa3e4 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 16 Jul 2025 13:23:28 -0700 Subject: [PATCH 24/51] feat: Refactor AndroidWrappedKeyProviderTest to use ISecretKeyProvider and parameterized tests --- .../crypto/AndroidWrappedKeyProviderTest.java | 119 ++++++++++++++---- 1 file changed, 94 insertions(+), 25 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index 5b7a33423e..eb5b484f0e 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -31,6 +31,7 @@ import com.microsoft.identity.common.adal.internal.AuthenticationSettings; import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.crypto.key.ISecretKeyProvider; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.util.FileUtil; @@ -38,19 +39,50 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; import java.io.File; +import java.lang.reflect.Constructor; import java.math.BigInteger; import java.security.KeyPair; import java.security.spec.AlgorithmParameterSpec; import java.util.Arrays; +import java.util.Collection; import java.util.Date; import javax.crypto.SecretKey; import javax.security.auth.x500.X500Principal; +@RunWith(Parameterized.class) public class AndroidWrappedKeyProviderTest { + + @Parameterized.Parameter(0) + public String providerName; + + @Parameterized.Parameter(1) + public Class providerClass; + + @Parameterized.Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][] { + {"KeyStoreBackedSecretKeyProvider", KeyStoreBackedSecretKeyProvider.class}, + {"AndroidWrappedKeyProvider", AndroidWrappedKeyProvider.class} + // Add other implementations here + }); + } + + private ISecretKeyProvider createProvider() { + try { + Constructor constructor = + providerClass.getDeclaredConstructor(String.class, String.class, Context.class); + return constructor.newInstance(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + } catch (Exception e) { + throw new RuntimeException("Failed to create provider instance", e); + } + } + final Context context = ApplicationProvider.getApplicationContext(); final String MOCK_KEY_ALIAS = "MOCK_KEY_ALIAS"; final String MOCK_KEY_FILE_PATH = "MOCK_KEY_FILE_PATH"; @@ -111,17 +143,17 @@ private AlgorithmParameterSpec getMockKeyPairGeneratorSpec(final String alias) { @Test public void testGenerateKey() throws ClientException { - final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - final SecretKey secretKey = keyProvider.generateNewSecretKey(); + final ISecretKeyProvider keyProvider = createProvider(); + final SecretKey secretKey = generateNewSecretKey(keyProvider); Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); } @Test public void testReadKeyDirectly() throws ClientException { - final KeyStoreBackedSecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final ISecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); final SecretKey secretKey = keyProvider.getKey(); - final SecretKey storedSecretKey = keyProvider.readSecretKeyFromStorage(); + final SecretKey storedSecretKey = readSecretKeyFromStorage(keyProvider); // They're not the same object! Assert.assertNotSame(secretKey, storedSecretKey); @@ -141,10 +173,10 @@ public void testLoadKey() throws ClientException { Assert.assertNull(AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); Assert.assertNull(FileUtil.readFromFile(getKeyFile(), KeyStoreBackedSecretKeyProvider.KEY_FILE_SIZE)); - final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final ISecretKeyProvider keyProvider = createProvider(); final SecretKey secretKey = keyProvider.getKey(); - final SecretKey key = keyProvider.getKeyFromCache(); + final SecretKey key = getKeyFromCache(keyProvider); Assert.assertNotNull(key); Assert.assertEquals(AES_ALGORITHM, secretKey.getAlgorithm()); Assert.assertArrayEquals(secretKey.getEncoded(), key.getEncoded()); @@ -154,8 +186,8 @@ public void testLoadKey() throws ClientException { @Test public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientException { // Create a new Keystore-wrapped key. - final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - keyProvider.generateNewSecretKey(); + final ISecretKeyProvider keyProvider = createProvider(); + generateNewSecretKey(keyProvider); final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), KeyStoreBackedSecretKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); @@ -165,7 +197,7 @@ public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientExce // It should fail to read, with an exception, and everything should be wiped. try{ - keyProvider.readSecretKeyFromStorage(); + readSecretKeyFromStorage(keyProvider); Assert.fail(); } catch (ClientException e){ Assert.assertEquals(INVALID_KEY, e.getErrorCode()); @@ -175,14 +207,14 @@ public void testLoadKeyFromCorruptedFile_TruncatedExisingKey() throws ClientExce Assert.assertFalse(getKeyFile().exists()); // the next read should be unblocked. - Assert.assertNull(keyProvider.readSecretKeyFromStorage()); + Assert.assertNull(readSecretKeyFromStorage(keyProvider)); } @Test public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException { // Create a new Keystore-wrapped key. - final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - keyProvider.generateNewSecretKey(); + final ISecretKeyProvider keyProvider = createProvider(); + generateNewSecretKey(keyProvider); final byte[] wrappedKey = FileUtil.readFromFile(getKeyFile(), KeyStoreBackedSecretKeyProvider.KEY_FILE_SIZE); Assert.assertNotNull(wrappedKey); @@ -192,7 +224,7 @@ public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException // It should fail to read, with an exception, and everything should be wiped. try{ - keyProvider.readSecretKeyFromStorage(); + readSecretKeyFromStorage(keyProvider); Assert.fail(); } catch (ClientException e){ Assert.assertEquals(INVALID_KEY, e.getErrorCode()); @@ -202,14 +234,14 @@ public void testLoadKeyFromCorruptedFile_InjectGarbage() throws ClientException Assert.assertFalse(getKeyFile().exists()); // the next read should be unblocked. - Assert.assertNull(keyProvider.readSecretKeyFromStorage()); + Assert.assertNull(readSecretKeyFromStorage(keyProvider)); } // 1s With Google Pixel XL, OS Version 29 (100 loop) @Test @Ignore public void testPerf_WithCachedKey() throws ClientException { - final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final ISecretKeyProvider keyProvider = createProvider(); long timeStartLoop = System.nanoTime(); for (int i = 0; i < TEST_LOOP; i++) { @@ -224,11 +256,11 @@ public void testPerf_WithCachedKey() throws ClientException { @Test @Ignore public void testPerf_NoCachedKey() throws ClientException { - final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + final ISecretKeyProvider keyProvider = createProvider(); long timeStartLoopNotCached = System.nanoTime(); for (int i = 0; i < 100; i++) { - keyProvider.clearKeyFromCache(); + clearKeyFromCache(keyProvider); keyProvider.getKey(); } long timeFinishLoopNotCached = System.nanoTime(); @@ -241,31 +273,68 @@ public void testPerf_NoCachedKey() throws ClientException { */ @Test public void testLoadDeletedKeyStoreKey() throws ClientException { - final KeyStoreBackedSecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final ISecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); // Cached key also be wiped. - final SecretKey key = keyProvider.getKeyFromCache(); + final SecretKey key = getKeyFromCache(keyProvider); Assert.assertNull(key); } @Test public void testLoadDeletedKeyFile() throws ClientException { - final KeyStoreBackedSecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); + final ISecretKeyProvider keyProvider = initkeyProviderWithKeyEntry(); FileUtil.deleteFile(getKeyFile()); // Cached key also be wiped. - final SecretKey key = keyProvider.getKeyFromCache(); + final SecretKey key = getKeyFromCache(keyProvider); Assert.assertNull(key); } - private KeyStoreBackedSecretKeyProvider initkeyProviderWithKeyEntry() throws ClientException { - final KeyStoreBackedSecretKeyProvider keyProvider = new KeyStoreBackedSecretKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + private ISecretKeyProvider initkeyProviderWithKeyEntry() throws ClientException { + final ISecretKeyProvider keyProvider = createProvider(); final SecretKey key = keyProvider.getKey(); - Assert.assertNotNull(key); - Assert.assertNotNull(keyProvider.getKeyFromCache()); + Assert.assertNotNull(getKeyFromCache(keyProvider)); return keyProvider; } + + private SecretKey getKeyFromCache(ISecretKeyProvider keyProvider) { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + return ((AndroidWrappedKeyProvider) keyProvider).getKeyFromCache(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + return ((KeyStoreBackedSecretKeyProvider) keyProvider).getKeyFromCache(); + } + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + + private void clearKeyFromCache(ISecretKeyProvider keyProvider) { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + ((AndroidWrappedKeyProvider) keyProvider).clearKeyFromCache(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + ((KeyStoreBackedSecretKeyProvider) keyProvider).clearKeyFromCache(); + } else { + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + } + + private SecretKey readSecretKeyFromStorage(ISecretKeyProvider keyProvider) throws ClientException { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + return ((AndroidWrappedKeyProvider) keyProvider).readSecretKeyFromStorage(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + return ((KeyStoreBackedSecretKeyProvider) keyProvider).readSecretKeyFromStorage(); + } + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + + private SecretKey generateNewSecretKey(ISecretKeyProvider keyProvider) throws ClientException { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + return ((AndroidWrappedKeyProvider) keyProvider).generateRandomKey(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + return ((KeyStoreBackedSecretKeyProvider) keyProvider).generateNewSecretKey(); + } + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + } From 9f5c380ff112d30fbe778541a9fdb0db8791bdf2 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 16 Jul 2025 13:26:31 -0700 Subject: [PATCH 25/51] feat: Add key management methods to AndroidWrappedKeyProviderTest for better key handling --- .../crypto/AndroidWrappedKeyProviderTest.java | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index eb5b484f0e..17472bc854 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -83,6 +83,43 @@ private ISecretKeyProvider createProvider() { } } + private SecretKey getKeyFromCache(ISecretKeyProvider keyProvider) { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + return ((AndroidWrappedKeyProvider) keyProvider).getKeyFromCache(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + return ((KeyStoreBackedSecretKeyProvider) keyProvider).getKeyFromCache(); + } + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + + private void clearKeyFromCache(ISecretKeyProvider keyProvider) { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + ((AndroidWrappedKeyProvider) keyProvider).clearKeyFromCache(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + ((KeyStoreBackedSecretKeyProvider) keyProvider).clearKeyFromCache(); + } else { + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + } + + private SecretKey readSecretKeyFromStorage(ISecretKeyProvider keyProvider) throws ClientException { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + return ((AndroidWrappedKeyProvider) keyProvider).readSecretKeyFromStorage(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + return ((KeyStoreBackedSecretKeyProvider) keyProvider).readSecretKeyFromStorage(); + } + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + + private SecretKey generateNewSecretKey(ISecretKeyProvider keyProvider) throws ClientException { + if (keyProvider instanceof AndroidWrappedKeyProvider) { + return ((AndroidWrappedKeyProvider) keyProvider).generateRandomKey(); + } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { + return ((KeyStoreBackedSecretKeyProvider) keyProvider).generateNewSecretKey(); + } + throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); + } + final Context context = ApplicationProvider.getApplicationContext(); final String MOCK_KEY_ALIAS = "MOCK_KEY_ALIAS"; final String MOCK_KEY_FILE_PATH = "MOCK_KEY_FILE_PATH"; @@ -296,45 +333,8 @@ public void testLoadDeletedKeyFile() throws ClientException { private ISecretKeyProvider initkeyProviderWithKeyEntry() throws ClientException { final ISecretKeyProvider keyProvider = createProvider(); final SecretKey key = keyProvider.getKey(); + Assert.assertNotNull(key); Assert.assertNotNull(getKeyFromCache(keyProvider)); return keyProvider; } - - private SecretKey getKeyFromCache(ISecretKeyProvider keyProvider) { - if (keyProvider instanceof AndroidWrappedKeyProvider) { - return ((AndroidWrappedKeyProvider) keyProvider).getKeyFromCache(); - } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { - return ((KeyStoreBackedSecretKeyProvider) keyProvider).getKeyFromCache(); - } - throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); - } - - private void clearKeyFromCache(ISecretKeyProvider keyProvider) { - if (keyProvider instanceof AndroidWrappedKeyProvider) { - ((AndroidWrappedKeyProvider) keyProvider).clearKeyFromCache(); - } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { - ((KeyStoreBackedSecretKeyProvider) keyProvider).clearKeyFromCache(); - } else { - throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); - } - } - - private SecretKey readSecretKeyFromStorage(ISecretKeyProvider keyProvider) throws ClientException { - if (keyProvider instanceof AndroidWrappedKeyProvider) { - return ((AndroidWrappedKeyProvider) keyProvider).readSecretKeyFromStorage(); - } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { - return ((KeyStoreBackedSecretKeyProvider) keyProvider).readSecretKeyFromStorage(); - } - throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); - } - - private SecretKey generateNewSecretKey(ISecretKeyProvider keyProvider) throws ClientException { - if (keyProvider instanceof AndroidWrappedKeyProvider) { - return ((AndroidWrappedKeyProvider) keyProvider).generateRandomKey(); - } else if (keyProvider instanceof KeyStoreBackedSecretKeyProvider) { - return ((KeyStoreBackedSecretKeyProvider) keyProvider).generateNewSecretKey(); - } - throw new IllegalArgumentException("Unsupported key provider type: " + keyProvider.getClass().getName()); - } - } From 9373bb842afdb2c5a32f600d795200bc5e542d2f Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 16 Jul 2025 15:21:00 -0700 Subject: [PATCH 26/51] feat: Replace AndroidWrappedKeyProvider with KeyStoreBackedSecretKeyProviderFactory for improved key management --- .../common/crypto/AndroidAuthSdkStorageEncryptionManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 55a9e1b6ec..283cdecd57 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 @@ -64,7 +64,7 @@ public AndroidAuthSdkStorageEncryptionManager(@NonNull final Context context) { AuthenticationSettings.INSTANCE.getSecretKeyData()); } - mKeyStoreKeyProvider = new AndroidWrappedKeyProvider( + mKeyStoreKeyProvider = KeyStoreBackedSecretKeyProviderFactory.INSTANCE.create( WRAPPING_KEY_ALIAS, WRAPPED_KEY_FILE_NAME, context From 82e7a6c78a1566ead6297e047e7348951962e0e9 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 17 Jul 2025 22:40:02 -0700 Subject: [PATCH 27/51] feat: Add instrumented tests for KeyStoreBackedSecretKeyProvider and enhance cipher specifications --- ...ckedSecretKeyProviderInstrumentedTest.java | 476 ++++++++++++++++++ .../identity/common/crypto/CipherSpec.kt | 34 ++ .../crypto/CryptoParameterSpecFactory.kt | 45 +- .../identity/common/crypto/KeyGenSpec.kt | 4 +- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 100 ++-- .../KeyStoreBackedSecretKeyProviderFactory.kt | 6 +- .../crypto/CryptoParameterSpecFactoryTest.kt | 3 +- .../common/java/flighting/CommonFlight.java | 2 +- 8 files changed, 590 insertions(+), 80 deletions(-) create mode 100644 common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java new file mode 100644 index 0000000000..06b6cae9ed --- /dev/null +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java @@ -0,0 +1,476 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import static com.microsoft.identity.common.java.exception.ClientException.INVALID_KEY; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.microsoft.identity.common.adal.internal.AuthenticationSettings; +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.crypto.key.KeyUtil; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; +import com.microsoft.identity.common.java.util.FileUtil; +import com.microsoft.identity.common.logging.Logger; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import java.io.File; +import java.util.Arrays; + +import javax.crypto.SecretKey; + +/** + * Instrumented tests for KeyStoreBackedSecretKeyProvider. + * These tests run on real Android devices/emulators with access to the Android KeyStore. + */ +@RunWith(AndroidJUnit4.class) +public class KeyStoreBackedSecretKeyProviderInstrumentedTest { + + private final Context context = ApplicationProvider.getApplicationContext(); + private final String MOCK_KEY_ALIAS = "MOCK_KEY_ALIAS_INSTRUMENTED"; + private final String MOCK_KEY_FILE_PATH = "MOCK_KEY_FILE_PATH_INSTRUMENTED"; + private final String AES_ALGORITHM = "AES"; + + private KeyStoreBackedSecretKeyProvider keyProvider; + + @BeforeClass + public static void classSetUp() { + Logger.setAndroidLogger(); + Logger.setAllowLogcat(true); + } + + @Before + public void setUp() throws Exception { + // Clean slate for each test + AuthenticationSettings.INSTANCE.clearSecretKeysForTestCases(); + AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); + FileUtil.deleteFile(getKeyFile()); + + final Context mockContext = Mockito.mock(Context.class); + final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(false); + final CryptoParameterSpecFactory cryptoParameterSpecFactory = new CryptoParameterSpecFactory( + mockContext, + MOCK_KEY_ALIAS, + mockFlightsProvider + ); + keyProvider = new KeyStoreBackedSecretKeyProvider( + context, + MOCK_KEY_ALIAS, + MOCK_KEY_FILE_PATH, + cryptoParameterSpecFactory + ); + } + + + private KeyStoreBackedSecretKeyProvider getOAEPKeyProvider() { + // Clean slate for each test + final Context mockContext = Mockito.mock(Context.class); + final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(true); + final CryptoParameterSpecFactory cryptoParameterSpecFactory = new CryptoParameterSpecFactory( + mockContext, + MOCK_KEY_ALIAS, + mockFlightsProvider + ); + return new KeyStoreBackedSecretKeyProvider( + context, + MOCK_KEY_ALIAS, + MOCK_KEY_FILE_PATH, + cryptoParameterSpecFactory + ); + } + + @After + public void tearDown() throws Exception { + // Clean up after each test + if (keyProvider != null) { + keyProvider.clearKeyFromCache(); + keyProvider.deleteSecretKeyFromStorage(); + } + AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); + FileUtil.deleteFile(getKeyFile()); + } + + private File getKeyFile() { + return new File( + context.getDir(context.getPackageName(), Context.MODE_PRIVATE), + MOCK_KEY_FILE_PATH + ); + } + + @Test + public void testBasicProperties() { + Assert.assertEquals("Alias should match", MOCK_KEY_ALIAS, keyProvider.getAlias()); + Assert.assertEquals("Key type identifier should be A001", "A001", keyProvider.getKeyTypeIdentifier()); + Assert.assertEquals("Cipher transformation should be AES/CBC/PKCS5Padding", + "AES/CBC/PKCS5Padding", keyProvider.getCipherTransformation()); + } + + @Test + public void testGetKey_GeneratesWhenNoneExists() throws ClientException { + // Initially no key should exist + Assert.assertFalse("Key file should not exist initially", getKeyFile().exists()); + Assert.assertNull("No key should be in KeyStore initially", AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); + + SecretKey secretKey = keyProvider.getKey(); + + Assert.assertNotNull("Key should be generated", secretKey); + Assert.assertEquals("Algorithm should be AES", AES_ALGORITHM, secretKey.getAlgorithm()); + Assert.assertNotNull("Key is cached", keyProvider.getKeyFromCache()); + Assert.assertTrue("Key file should be created", getKeyFile().exists()); + Assert.assertNotNull("KeyPair should be in KeyStore", AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); + } + + @Test + public void testGetKey_LoadsExistingKey() throws ClientException { + // First, generate a key + SecretKey originalKey = keyProvider.generateNewSecretKey(); + Assert.assertNotNull("Original key should not be null", originalKey); + + // Verify key is not cached + Assert.assertNull("Cache must be empty", keyProvider.getKeyFromCache()); + + // Get key again - should load from storage + SecretKey loadedKey = keyProvider.getKey(); + + Assert.assertNotNull("Loaded key should not be null", loadedKey); + Assert.assertEquals("Algorithm should be AES", AES_ALGORITHM, loadedKey.getAlgorithm()); + + // Keys should have the same thumbprint (same key material) + String originalThumbprint = KeyUtil.getKeyThumbPrint(originalKey); + String loadedThumbprint = KeyUtil.getKeyThumbPrint(loadedKey); + Assert.assertEquals("Thumbprints should match", originalThumbprint, loadedThumbprint); + } + + @Test + public void testCaching() throws ClientException { + // First call should generate and cache the key + SecretKey key1 = keyProvider.getKey(); + // Second call should return the same cached instance + SecretKey key2 = keyProvider.getKeyFromCache(); + Assert.assertSame("Keys should be the same cached instance", key1, key2); + } + + @Test + public void testClearKeyFromCache() throws ClientException { + // Generate and cache a key + SecretKey originalKey = keyProvider.getKey(); + Assert.assertNotNull("Original key should not be null", originalKey); + + // Verify it's cached + SecretKey cachedKey = keyProvider.getKeyFromCache(); + Assert.assertSame("Should return cached key", originalKey, cachedKey); + + // Clear cache + keyProvider.clearKeyFromCache(); + + // Cache should be empty + SecretKey afterClearCache = keyProvider.getKeyFromCache(); + Assert.assertNull("Cache should be empty after clearing", afterClearCache); + + // But we should still be able to load from storage + SecretKey reloadedKey = keyProvider.getKey(); + Assert.assertNotNull("Should be able to reload from storage", reloadedKey); + + // Should have same key material + String originalThumbprint = KeyUtil.getKeyThumbPrint(originalKey); + String reloadedThumbprint = KeyUtil.getKeyThumbPrint(reloadedKey); + Assert.assertEquals("Thumbprints should match", originalThumbprint, reloadedThumbprint); + } + + + @Test + public void testDeleteSecretKeyFromStorage() throws ClientException { + // Generate a key first + keyProvider.generateNewSecretKey(); + Assert.assertTrue("Key file should exist", getKeyFile().exists()); + Assert.assertNotNull("KeyPair should exist in KeyStore", AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); + + // Delete from storage + keyProvider.deleteSecretKeyFromStorage(); + + Assert.assertFalse("Key file should be deleted", getKeyFile().exists()); + Assert.assertNull("KeyPair should be deleted from KeyStore", AndroidKeyStoreUtil.readKey(MOCK_KEY_ALIAS)); + + // Cache should also be cleared + SecretKey cachedKey = keyProvider.getKeyFromCache(); + Assert.assertNull("Cache should be cleared", cachedKey); + } + + @Test + public void testKeyInvalidation_DeletedKeyStoreKey() throws ClientException { + // Generate a key and ensure it's cached + SecretKey originalKey = keyProvider.getKey(); + Assert.assertNotNull("Original key should not be null", originalKey); + Assert.assertNotNull("Should be cached", keyProvider.getKeyFromCache()); + + // Manually delete the KeyStore key (simulating external deletion) + AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); + + // Cache should be invalidated + SecretKey cachedKey = keyProvider.getKeyFromCache(); + Assert.assertNull("Cache should be invalidated when KeyStore key is deleted", cachedKey); + } + + @Test + public void testKeyInvalidation_DeletedKeyFile() throws ClientException { + // Generate a key and ensure it's cached + SecretKey originalKey = keyProvider.getKey(); + Assert.assertNotNull("Original key should not be null", originalKey); + Assert.assertNotNull("Should be cached", keyProvider.getKeyFromCache()); + + // Manually delete the key file (simulating external deletion) + FileUtil.deleteFile(getKeyFile()); + + // Cache should be invalidated + SecretKey cachedKey = keyProvider.getKeyFromCache(); + Assert.assertNull("Cache should be invalidated when key file is deleted", cachedKey); + } + + @Test + public void testCorruptedKeyFile_TruncatedData() throws ClientException { + // Generate a key first + keyProvider.generateNewSecretKey(); + + // Read the wrapped key data + byte[] originalWrappedKey = FileUtil.readFromFile(getKeyFile(), KeyStoreBackedSecretKeyProvider.KEY_FILE_SIZE); + Assert.assertNotNull("Original wrapped key should exist", originalWrappedKey); + + // Corrupt the file by truncating it + byte[] truncatedData = Arrays.copyOfRange(originalWrappedKey, 0, originalWrappedKey.length / 2); + FileUtil.writeDataToFile(truncatedData, getKeyFile()); + + // Clear cache to force reading from corrupted file + keyProvider.clearKeyFromCache(); + + // Should throw exception when trying to read corrupted key + try { + keyProvider.readSecretKeyFromStorage(); + Assert.fail("Should throw exception for corrupted key"); + } catch (ClientException e) { + Assert.assertEquals("Should throw INVALID_KEY exception", INVALID_KEY, e.getErrorCode()); + } + + // File should be cleaned up after failed read + Assert.assertFalse("Corrupted file should be deleted", getKeyFile().exists()); + + // Next read should work (will generate new key) + SecretKey newKey = keyProvider.readSecretKeyFromStorage(); + Assert.assertNull("Should return null after cleanup, requiring new key generation", newKey); + } + + @Test + public void testCorruptedKeyFile_GarbageData() throws ClientException { + // Generate a key first + keyProvider.generateNewSecretKey(); + + // Corrupt the file with garbage data + byte[] garbageData = {10, 20, 30, 40, 50}; + FileUtil.writeDataToFile(garbageData, getKeyFile()); + + // Clear cache to force reading from corrupted file + keyProvider.clearKeyFromCache(); + + // Should throw exception when trying to read corrupted key + try { + keyProvider.readSecretKeyFromStorage(); + Assert.fail("Should throw exception for corrupted key"); + } catch (ClientException e) { + Assert.assertEquals("Should throw INVALID_KEY exception", INVALID_KEY, e.getErrorCode()); + } + + // File should be cleaned up after failed read + Assert.assertFalse("Corrupted file should be deleted", getKeyFile().exists()); + } + + @Test + public void testRealWorldScenario_MultipleOperations() throws ClientException { + // Simulate real-world usage with multiple operations + + // 1. App starts, generates key + SecretKey key1 = keyProvider.getKey(); + Assert.assertNotNull("Initial key should not be null", key1); + String thumbprint1 = KeyUtil.getKeyThumbPrint(key1); + + // 2. App restarts (cache cleared) + keyProvider.clearKeyFromCache(); + SecretKey key2 = keyProvider.getKey(); + Assert.assertNotNull("Key after restart should not be null", key2); + String thumbprint2 = KeyUtil.getKeyThumbPrint(key2); + Assert.assertEquals("Key should be the same after restart", thumbprint1, thumbprint2); + + // 3. Multiple rapid accesses (should use cache) + for (int i = 0; i < 10; i++) { + SecretKey rapidKey = keyProvider.getKey(); + Assert.assertSame("Rapid access should return cached key", key2, rapidKey); + } + + // 4. Manual key regeneration + SecretKey newKey = keyProvider.generateNewSecretKey(); + Assert.assertNotNull("New key should not be null", newKey); + String newThumbprint = KeyUtil.getKeyThumbPrint(newKey); + Assert.assertNotEquals("New key should be different", thumbprint1, newThumbprint); + } + + // Backward/Forward Compatibility Tests AndroidWrappedKeyProvider to KeyStoreBackedSecretKeyProvider using PKCS1 + + @Test + public void testBackwardCompatibility_KeyTypeIdentifierConsistency() { + AndroidWrappedKeyProvider androidProvider = new AndroidWrappedKeyProvider( + MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + + Assert.assertEquals("Key type identifiers should match", + androidProvider.getKeyTypeIdentifier(), + keyProvider.getKeyTypeIdentifier()); + Assert.assertEquals("Key type identifier should be A001", "A001", keyProvider.getKeyTypeIdentifier()); + } + + @Test + public void testBackwardCompatibility_CipherTransformationConsistency() { + AndroidWrappedKeyProvider androidProvider = new AndroidWrappedKeyProvider( + MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + + Assert.assertEquals("Cipher transformations should match", + androidProvider.getCipherTransformation(), + keyProvider.getCipherTransformation()); + Assert.assertEquals("Cipher transformation should be AES/CBC/PKCS5Padding", + "AES/CBC/PKCS5Padding", keyProvider.getCipherTransformation()); + } + + @Test + public void testMigrationScenario_UpgradeFromAndroidWrappedKeyProvider() throws ClientException { + // Scenario: App was using AndroidWrappedKeyProvider, now upgrading to KeyStoreBackedSecretKeyProvider + + // Step 1: Create data with AndroidWrappedKeyProvider + AndroidWrappedKeyProvider androidProvider = new AndroidWrappedKeyProvider( + MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + SecretKey originalKey = androidProvider.generateRandomKey(); + String originalThumbprint = KeyUtil.getKeyThumbPrint(originalKey); + + // Step 2: App upgrades and now uses KeyStoreBackedSecretKeyProvider + // It should be able to read the existing key + SecretKey migratedKey = keyProvider.getKey(); + String migratedThumbprint = KeyUtil.getKeyThumbPrint(migratedKey); + + Assert.assertNotNull("Migrated key should not be null", migratedKey); + Assert.assertEquals("Thumbprints should match after migration", originalThumbprint, migratedThumbprint); + + // Step 3: Key should now be cached in the new provider + SecretKey cachedKey = keyProvider.getKeyFromCache(); + Assert.assertSame("Key should be cached", migratedKey, cachedKey); + + // Step 4: Subsequent operations should work normally + SecretKey subsequentKey = keyProvider.getKey(); + Assert.assertSame("Subsequent calls should return cached key", migratedKey, subsequentKey); + } + + @Test + public void testMigrationScenario_RollbackToAndroidWrappedKeyProvider() throws ClientException { + // Scenario: App was using KeyStoreBackedSecretKeyProvider, now rolling back to AndroidWrappedKeyProvider + + // Step 1: Create data with KeyStoreBackedSecretKeyProvider + SecretKey originalKey = keyProvider.generateNewSecretKey(); + String originalThumbprint = KeyUtil.getKeyThumbPrint(originalKey); + + // Step 2: App rolls back and now uses AndroidWrappedKeyProvider + // It should be able to read the existing key + AndroidWrappedKeyProvider androidProvider = new AndroidWrappedKeyProvider( + MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + SecretKey rolledBackKey = androidProvider.readSecretKeyFromStorage(); + + Assert.assertNotNull("Rolled back key should not be null", rolledBackKey); + String rolledBackThumbprint = KeyUtil.getKeyThumbPrint(rolledBackKey); + Assert.assertEquals("Thumbprints should match after rollback", originalThumbprint, rolledBackThumbprint); + + } + + // Compatibility Tests KeyStoreBackedSecretKeyProvider using PKCS1 and OAEP providers + + @Test + public void testBackwardCompatibility_PKCS1ToOAEP_KeyCreation() throws ClientException { + // Test that a key created with PKCS1 provider can be read by OAEP provider + + // Step 1: Create key with PKCS1 provider (regular keyProvider) + SecretKey pkcs1Key = keyProvider.generateNewSecretKey(); + Assert.assertNotNull("PKCS1 provider key should not be null", pkcs1Key); + String pkcs1Thumbprint = KeyUtil.getKeyThumbPrint(pkcs1Key); + + // Step 2: Try to read the same key with OAEP provider + KeyStoreBackedSecretKeyProvider oaepProvider = getOAEPKeyProvider(); + SecretKey oaepReadKey = oaepProvider.readSecretKeyFromStorage(); + + Assert.assertNotNull("OAEP provider should read PKCS1 created key", oaepReadKey); + String oaepThumbprint = KeyUtil.getKeyThumbPrint(oaepReadKey); + Assert.assertEquals("Thumbprints should match between PKCS1 and OAEP", pkcs1Thumbprint, oaepThumbprint); + + // Step 3: Verify both providers use same key type identifier and cipher transformation + Assert.assertEquals("Key type identifiers should match", + keyProvider.getKeyTypeIdentifier(), oaepProvider.getKeyTypeIdentifier()); + Assert.assertEquals("Cipher transformations should match", + keyProvider.getCipherTransformation(), oaepProvider.getCipherTransformation()); + } + + @Test + public void testBackwardCompatibility_OAEPToPKCS1_CachedKey() throws ClientException { + // Test cached key compatibility between OAEP and PKCS1 providers + + // Step 1: Generate and cache key with OAEP provider + KeyStoreBackedSecretKeyProvider oaepProvider = getOAEPKeyProvider(); + SecretKey oaepKey = oaepProvider.getKey(); + Assert.assertNotNull("OAEP cached key should not be null", oaepProvider.getKeyFromCache()); + + keyProvider.clearKeyFromCache(); + // Step 2: PKCS1 provider should be able to read the same key + SecretKey pkcs1Key = keyProvider.getKey(); + + Assert.assertNotNull("PKCS1 provider should get key", pkcs1Key); + + // Step 3: Keys should have same thumbprint + String oaepThumbprint = KeyUtil.getKeyThumbPrint(oaepKey); + String pkcs1Thumbprint = KeyUtil.getKeyThumbPrint(pkcs1Key); + Assert.assertEquals("Cached keys should have same thumbprint", oaepThumbprint, pkcs1Thumbprint); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt index 45a2a0d3b1..58d6d39cb6 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CipherSpec.kt @@ -23,6 +23,9 @@ package com.microsoft.identity.common.crypto import java.security.spec.AlgorithmParameterSpec +import java.security.spec.MGF1ParameterSpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource /** * Cipher parameter specification for cryptographic operations. @@ -51,4 +54,35 @@ data class CipherSpec( override fun toString(): String { return "CipherSpec(transformation='$transformation')" } + + companion object { + private const val RSA_ALGORITHM = "RSA" + private const val OAEP_PADDING_WITH_256MGF1 = "OAEPwithSHA-256andMGF1Padding" + private const val MODE_ECB = "ECB" + private const val MODE_NONE = "NONE" + private const val PKCS1_PADDING = "PKCS1Padding" + + // OAEP parameter specification for RSA encryption + private val OAEP_SPECS = OAEPParameterSpec( + "SHA-256", // main digest + "MGF1", // mask generation function + MGF1ParameterSpec.SHA1, // MGF1 digest + PSource.PSpecified.DEFAULT // label (usually default) + ) + + // Cipher parameter specifications + val pkcs1CipherSpec = CipherSpec( + algorithmParameterSpec = null, + algorithm = RSA_ALGORITHM, + mode = MODE_ECB, + padding = PKCS1_PADDING + ) + + val oaepCipherSpec = CipherSpec( + algorithmParameterSpec = OAEP_SPECS, + algorithm = RSA_ALGORITHM, + mode = MODE_NONE, + padding = OAEP_PADDING_WITH_256MGF1, + ) + } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index 2f682cedd4..804225dfd9 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -30,9 +30,6 @@ import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager.getFlightsProvider import com.microsoft.identity.common.java.flighting.IFlightsProvider import com.microsoft.identity.common.logging.Logger -import java.security.spec.MGF1ParameterSpec -import javax.crypto.spec.OAEPParameterSpec -import javax.crypto.spec.PSource /** * A factory for creating cryptographic parameter specifications for key generation and cipher operations. @@ -80,20 +77,6 @@ class CryptoParameterSpecFactory( private const val MODERN_SPEC_WITH_PURPOSE_WRAP_KEY = "modern_spec_with_wrap_key" private const val MODERN_SPEC_WITHOUT_PURPOSE_WRAP_KEY = "modern_spec_without_wrap_key" private const val LEGACY_SPEC = "legacy_key_gen_spec" - - // Padding schemes and modes used in cipher operations - private const val PKCS1_PADDING = "PKCS1Padding" - private const val OAEP_PADDING_WITH_256MGF1 = "OAEPwithSHA-256andMGF1Padding" - private const val MODE_ECB = "ECB" - private const val MODE_NONE = "NONE" - - // OAEP parameter specification for RSA encryption - private val OAEP_SPECS = OAEPParameterSpec( - "SHA-256", // main digest - "MGF1", // mask generation function - MGF1ParameterSpec.SHA1, // MGF1 digest - PSource.PSpecified.DEFAULT // label (usually default) - ) } // Feature flags to control which key generation specs to use @@ -116,21 +99,6 @@ class CryptoParameterSpecFactory( ) } - // Cipher parameter specifications - val pkcs1CipherSpec = CipherSpec( - algorithmParameterSpec = null, - algorithm = RSA_ALGORITHM, - mode = MODE_ECB, - padding = PKCS1_PADDING - ) - - private val oaepCipherSpec = CipherSpec( - algorithmParameterSpec = OAEP_SPECS, - algorithm = RSA_ALGORITHM, - mode = MODE_NONE, - padding = OAEP_PADDING_WITH_256MGF1, - ) - // Key generation parameter specifications @delegate:RequiresApi(Build.VERSION_CODES.P) private val keyGenParamSpecWithPurposeWrapKey by lazy { @@ -165,18 +133,18 @@ class CryptoParameterSpecFactory( encryptionPaddings = getEncryptionPaddingsForKeyGen(), algorithm = RSA_ALGORITHM ) - } + } private val keyGenParamSpecLegacy = LegacyKeyGenSpec( context = context, keyAlias = keyAlias, keySize = KEY_SIZE, description = LEGACY_SPEC, - encryptionPaddings = listOf(PKCS1_PADDING), + encryptionPaddings = listOf("PKCS1Padding"), algorithm = RSA_ALGORITHM ) - //TODO: check if additional flag is needed to enable OAEP padding + @RequiresApi(Build.VERSION_CODES.M) private fun getEncryptionPaddingsForKeyGen(): List { val paddings = mutableListOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) if (enableKeyGenEncryptionPaddingRsaOaep) { @@ -185,7 +153,6 @@ class CryptoParameterSpecFactory( return paddings } - /** * Returns cipher specifications ordered by security preference. * @@ -196,12 +163,14 @@ class CryptoParameterSpecFactory( */ fun getPrioritizedCipherParameterSpecs(): List { val methodTag = "$TAG:getPrioritizedCipherParameterSpecs" - val specs = listOf(oaepCipherSpec, pkcs1CipherSpec) + val specs = listOf( + CipherSpec.oaepCipherSpec, + CipherSpec.pkcs1CipherSpec + ) Logger.info(methodTag, "Ciphers: $specs") return specs } - /** * Returns key generation specifications ordered by API level compatibility. * diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt index 815174c263..7c122225e5 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt @@ -23,8 +23,10 @@ package com.microsoft.identity.common.crypto import android.content.Context +import android.os.Build import android.security.KeyPairGeneratorSpec import android.security.keystore.KeyGenParameterSpec +import androidx.annotation.RequiresApi import java.math.BigInteger import java.security.spec.AlgorithmParameterSpec import java.util.Calendar @@ -84,13 +86,13 @@ data class KeyGenSpec( return encryptionPaddings.toTypedArray() } + @RequiresApi(Build.VERSION_CODES.M) override val algorithmParameterSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(keyAlias, purposes) .setKeySize(keySize) .setDigests(*getDigestAlgorithms()) .setEncryptionPaddings(*getEncryptionPaddings()) .build() - } /** diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index e5fd0b4feb..91ec9c6359 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -48,16 +48,18 @@ import javax.crypto.SecretKey * The secret key is wrapped using a KeyPair stored in the Android KeyStore. * * @param alias The alias for the key in the Android KeyStore. - * @param mFilePath The file path where the wrapped secret key is stored. - * @param mContext The context used to access the Android KeyStore and file system. + * @param filePath The file path where the wrapped secret key is stored. + * @param context The context used to access the Android KeyStore and file system. */ -class KeyStoreBackedSecretKeyProvider( +class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( + context: Context, override val alias: String, - private val mFilePath: String, - mContext: Context + private val filePath: String, + private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory(context, alias) ) : ISecretKeyProvider { companion object { private const val TAG = "KeyStoreBackedSecretKeyProvider" + /** * AES is 16 bytes (128 bits), thus PKCS#5 padding should not work, but in * Java AES/CBC/PKCS5Padding is default(!) algorithm name, thus PKCS5 here @@ -82,28 +84,22 @@ class KeyStoreBackedSecretKeyProvider( override val keyTypeIdentifier = KEY_TYPE_IDENTIFIER override val cipherTransformation = AES_CBC_PKCS5_PADDING_TRANSFORMATION - /** - * CryptoParameterSpecFactory is used to select the compatible cipher spec for wrapping/unwrapping the secret key. - */ - private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = - CryptoParameterSpecFactory(mContext, alias) - /** * File where the wrapped secret key is stored. */ private val keyFile = - File(mContext.getDir(mContext.packageName, Context.MODE_PRIVATE), mFilePath) + File(context.getDir(context.packageName, Context.MODE_PRIVATE), filePath) @get:VisibleForTesting val keyFromCache: SecretKey? get() { clearCachedKeyIfCantLoadOrFileDoesNotExist() - return sKeyCacheMap[mFilePath] + return sKeyCacheMap[filePath] } @VisibleForTesting fun clearKeyFromCache() { - sKeyCacheMap.remove(mFilePath) + sKeyCacheMap.remove(filePath) } /** @@ -114,14 +110,14 @@ class KeyStoreBackedSecretKeyProvider( fun deleteSecretKeyFromStorage() { AndroidKeyStoreUtil.deleteKey(alias) FileUtil.deleteFile(keyFile) - sKeyCacheMap.remove(mFilePath) + sKeyCacheMap.remove(filePath) } private fun clearCachedKeyIfCantLoadOrFileDoesNotExist() { val shouldClearCache = !KeyStoreBackedSecretKeyProviderFactory.skipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists()) if (shouldClearCache) { - sKeyCacheMap.remove(mFilePath) + sKeyCacheMap.remove(filePath) } } @@ -149,7 +145,7 @@ class KeyStoreBackedSecretKeyProvider( } readSecretKeyFromStorage()?.let { - sKeyCacheMap[mFilePath] = it + sKeyCacheMap[filePath] = it Logger.info( methodTag, "Key loaded from storage and cached with thumbprint: " + @@ -159,7 +155,7 @@ class KeyStoreBackedSecretKeyProvider( } val newKey = generateNewSecretKey() - sKeyCacheMap[mFilePath] = newKey + sKeyCacheMap[filePath] = newKey Logger.info( methodTag, "New key is generated and cached with thumbprint: " + @@ -198,12 +194,12 @@ class KeyStoreBackedSecretKeyProvider( */ val methodTag = "$TAG:generateRandomKey" val newSecretKey = AES256SecretKeyGenerator.generateRandomKey() - val keyPair : KeyPair = AndroidKeyStoreUtil.readKey(alias) + val keyPair: KeyPair = AndroidKeyStoreUtil.readKey(alias) ?: run { Logger.info(methodTag, "No existing keypair found. Generating a new one.") generateKeyPair() - } - val keyWrapped = wrapSecretKey(newSecretKey,keyPair) + } + val keyWrapped = wrapSecretKey(newSecretKey, keyPair) FileUtil.writeDataToFile(keyWrapped, keyFile) return newSecretKey } @@ -252,13 +248,19 @@ class KeyStoreBackedSecretKeyProvider( keyPair: KeyPair ): ByteArray { val methodTag = "$TAG:wrapSecretKey" - val span = OTelUtility.createSpanFromParent(SpanName.SecretKeyWrapping.name, SpanExtension.current().spanContext) + val span = OTelUtility.createSpanFromParent( + SpanName.SecretKeyWrapping.name, + SpanExtension.current().spanContext + ) return try { SpanExtension.makeCurrentSpan(span).use { _ -> span.setAttribute(AttributeName.secret_key_wrapping_operation.name, methodTag) val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - span.setAttribute(AttributeName.secret_key_wrapping_cipher.name, cipherParamsSpec.toString()) + span.setAttribute( + AttributeName.secret_key_wrapping_cipher.name, + cipherParamsSpec.toString() + ) Logger.info(methodTag, "Wrapping secret key with cipher spec: $cipherParamsSpec") val wrappedKey = AndroidKeyStoreUtil.wrap( secretKey, @@ -284,13 +286,19 @@ class KeyStoreBackedSecretKeyProvider( keyPair: KeyPair ): SecretKey { val methodTag = "$TAG:unwrapSecretKey" - val span = OTelUtility.createSpanFromParent(SpanName.SecretKeyWrapping.name, SpanExtension.current().spanContext) + val span = OTelUtility.createSpanFromParent( + SpanName.SecretKeyWrapping.name, + SpanExtension.current().spanContext + ) return try { SpanExtension.makeCurrentSpan(span).use { _ -> span.setAttribute(AttributeName.secret_key_wrapping_operation.name, methodTag) val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) - span.setAttribute(AttributeName.secret_key_wrapping_cipher.name, cipherParamsSpec.toString()) + span.setAttribute( + AttributeName.secret_key_wrapping_cipher.name, + cipherParamsSpec.toString() + ) Logger.info(methodTag, "Wrapping secret key with cipher spec: $cipherParamsSpec") val key = AndroidKeyStoreUtil.unwrap( wrappedSecretKey, @@ -333,14 +341,14 @@ class KeyStoreBackedSecretKeyProvider( for (cipherSpec in availableCipherSpecs) { for (padding in supportedPaddings) { if (cipherSpec.padding.contains(padding, ignoreCase = true)) { - Logger.info(methodTag, "Selected cipher spec: $cipherSpec") + Logger.info(methodTag, "Selected cipher spec: $cipherSpec") return cipherSpec } } } Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. - return cryptoParameterSpecFactory.pkcs1CipherSpec + return CipherSpec.pkcs1CipherSpec } /** @@ -355,24 +363,38 @@ class KeyStoreBackedSecretKeyProvider( @Throws(ClientException::class) private fun generateKeyPair(): KeyPair { val methodTag = "$TAG:generateKeyPair" - val span = OTelUtility.createSpanFromParent(SpanName.KeyPairGeneration.name, SpanExtension.current().spanContext) + val span = OTelUtility.createSpanFromParent( + SpanName.KeyPairGeneration.name, + SpanExtension.current().spanContext + ) val failures = mutableListOf() return try { SpanExtension.makeCurrentSpan(span).use { _ -> val specs = cryptoParameterSpecFactory.getPrioritizedKeyGenParameterSpecs() validateSpecsAvailable(specs) - for ((index, spec) in specs.withIndex()) { - Logger.verbose(methodTag, "Attempting key generation with spec ${index + 1}: $spec") + Logger.verbose( + methodTag, + "Attempting key generation with spec ${index + 1}: $spec" + ) attemptKeyGeneration(spec) .onSuccess { keyPair -> - Logger.info(methodTag, "Key pair generated successfully with spec: $spec") - span.setAttribute(AttributeName.key_pair_gen_successful_method.name, spec.toString()) + Logger.info( + methodTag, + "Key pair generated successfully with spec: $spec" + ) + span.setAttribute( + AttributeName.key_pair_gen_successful_method.name, + spec.toString() + ) span.setStatus(StatusCode.OK) return@use keyPair } .onFailure { throwable -> - Logger.warn(methodTag, "Failed to generate key pair with spec: $spec, error: ${throwable.message}") + Logger.warn( + methodTag, + "Failed to generate key pair with spec: $spec, error: ${throwable.message}" + ) failures.add(throwable) } } @@ -422,7 +444,8 @@ class KeyStoreBackedSecretKeyProvider( spec.algorithmParameterSpec ) val elapsedTime = System.currentTimeMillis() - startTime - SpanExtension.current().setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) + SpanExtension.current() + .setAttribute(AttributeName.elapsed_time_keypair_generation.name, elapsedTime) keyPair } } @@ -445,9 +468,14 @@ class KeyStoreBackedSecretKeyProvider( } failures.forEach { exception -> - Logger.error(methodTag, "Key pair generation failed with: ${exception.message}", exception) + Logger.error( + methodTag, + "Key pair generation failed with: ${exception.message}", + exception + ) } - SpanExtension.current().setAttribute(AttributeName.keypair_gen_exception.name, errorMessages) + SpanExtension.current() + .setAttribute(AttributeName.keypair_gen_exception.name, errorMessages) val finalError = failures.last() SpanExtension.current().setStatus(StatusCode.ERROR) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt index 622de6a877..a9bb72f517 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt @@ -82,9 +82,9 @@ object KeyStoreBackedSecretKeyProviderFactory { return if (enableKeyStoreBackedSecretKeyProvider) { KeyStoreBackedSecretKeyProvider( - keyIdentifier, - fileName, - context + context = context, + alias = keyIdentifier, + filePath = fileName ) } else { AndroidWrappedKeyProvider( diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt index d6486eebed..e77feff665 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt @@ -62,7 +62,8 @@ class CryptoParameterSpecFactoryTest { fun testGetPrioritizedCipherParameterSpec() { // Re-create the factory with the updated flags cryptoParameterSpecFactory = CryptoParameterSpecFactory( - mockContext!!, TEST_KEY_ALIAS, + mockContext!!, + TEST_KEY_ALIAS, mockFlightsProvider!! ) diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index bb2024c9ca..c2cf8539a2 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -124,7 +124,7 @@ public enum CommonFlight implements IFlightConfig { /** * Flight to enable the new KEK algorithm for encryption/decryption of keys. */ - ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING("EnableOAEPWithSHAAndMGF1Padding", true), + ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING("EnableOAEPWithSHAAndMGF1Padding", false), /** * Flight to enable the OAEP Android Wrapped Key Loader. From 9971a28309a6a04a2361c7bb33aa2360a9d3d7bf Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Thu, 17 Jul 2025 23:26:31 -0700 Subject: [PATCH 28/51] Update common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index 91ec9c6359..76a6593714 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -299,7 +299,7 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( AttributeName.secret_key_wrapping_cipher.name, cipherParamsSpec.toString() ) - Logger.info(methodTag, "Wrapping secret key with cipher spec: $cipherParamsSpec") + Logger.info(methodTag, "Unwrapping secret key with cipher spec: $cipherParamsSpec") val key = AndroidKeyStoreUtil.unwrap( wrappedSecretKey, AES256SecretKeyGenerator.AES_ALGORITHM, From c1de3ae946868483d51096a70381f205b8c38363 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Fri, 18 Jul 2025 12:52:04 -0700 Subject: [PATCH 29/51] update changelog --- changelog.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index f066b20cb4..7ac21048f7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,7 +1,8 @@ vNext ---------- +- [MAJOR] Add KeyStoreBackedSecretKeyProvider (#2674) - [PATCH] Fix caching of secret key and add retries for InvalidKeyException during unwrap (#2659) -- [MINOR] Replace AbstractSecretKeyLoader with ISecretKeyProvider (#2666) +- [MAJOR] Replace AbstractSecretKeyLoader with ISecretKeyProvider (#2666) - [MINOR] Update IP phone app teams signature constants to use SHA-512 format (#2700) Version 21.4.0 From 077a29fcaf9760787af2eae7fbda19a19f33ad4e Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 24 Jul 2025 17:32:46 -0700 Subject: [PATCH 30/51] refactor: Update flightsProvider usage and improve logging verbosity in KeyStoreBackedSecretKeyProvider --- .../common/crypto/CryptoParameterSpecFactory.kt | 10 +++++----- .../common/crypto/KeyStoreBackedSecretKeyProvider.kt | 11 +++-------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index 804225dfd9..8fb0841c42 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -61,7 +61,7 @@ import com.microsoft.identity.common.logging.Logger class CryptoParameterSpecFactory( context: Context, keyAlias: String, - flightsProvider: IFlightsProvider = getFlightsProvider() + val flightsProvider: IFlightsProvider = getFlightsProvider() ) { private companion object { @@ -79,12 +79,12 @@ class CryptoParameterSpecFactory( private const val LEGACY_SPEC = "legacy_key_gen_spec" } - // Feature flags to control which key generation specs to use - private val keySpecWithPurposeKey = + // Feature flags to control which key generation specs to use. (evaluated every time accessed) + private val keySpecWithPurposeKey get() = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) - private val keySpecWithoutPurposeKey = + private val keySpecWithoutPurposeKey get() = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) - private val enableKeyGenEncryptionPaddingRsaOaep = + private val enableKeyGenEncryptionPaddingRsaOaep get() = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) init { diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index 76a6593714..0c1bd49b52 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -136,17 +136,12 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( val methodTag = "$TAG:getKey" keyFromCache?.let { - Logger.info( - methodTag, - "Key is already cached, returning cached key with thumbprint: " + - KeyUtil.getKeyThumbPrint(it) - ) return it } readSecretKeyFromStorage()?.let { sKeyCacheMap[filePath] = it - Logger.info( + Logger.verbose( methodTag, "Key loaded from storage and cached with thumbprint: " + KeyUtil.getKeyThumbPrint(it) @@ -156,7 +151,7 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( val newKey = generateNewSecretKey() sKeyCacheMap[filePath] = newKey - Logger.info( + Logger.verbose( methodTag, "New key is generated and cached with thumbprint: " + KeyUtil.getKeyThumbPrint(newKey) @@ -192,7 +187,7 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( * application using a shared linux user id... and avoid these applications from * stomping/overwriting one another's keypair. */ - val methodTag = "$TAG:generateRandomKey" + val methodTag = "$TAG:generateNewSecretKey" val newSecretKey = AES256SecretKeyGenerator.generateRandomKey() val keyPair: KeyPair = AndroidKeyStoreUtil.readKey(alias) ?: run { From 2d4b140c1c90dc46a64419db3f2c9a8859ead553 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 24 Jul 2025 17:47:00 -0700 Subject: [PATCH 31/51] improve telemtry --- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 24 ++++++++---- .../java/opentelemetry/AttributeName.java | 19 +++++++++- .../identity/common/java/util/FileUtil.java | 37 +++++++------------ 3 files changed, 47 insertions(+), 33 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index 0c1bd49b52..d714ee7510 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -250,11 +250,11 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( return try { SpanExtension.makeCurrentSpan(span).use { _ -> - span.setAttribute(AttributeName.secret_key_wrapping_operation.name, methodTag) + span.setAttribute(AttributeName.secret_key_wrapping_operation.name, "WRAP") val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) span.setAttribute( - AttributeName.secret_key_wrapping_cipher.name, - cipherParamsSpec.toString() + AttributeName.secret_key_wrapping_transformation.name, + cipherParamsSpec.transformation ) Logger.info(methodTag, "Wrapping secret key with cipher spec: $cipherParamsSpec") val wrappedKey = AndroidKeyStoreUtil.wrap( @@ -288,11 +288,11 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( return try { SpanExtension.makeCurrentSpan(span).use { _ -> - span.setAttribute(AttributeName.secret_key_wrapping_operation.name, methodTag) + span.setAttribute(AttributeName.secret_key_wrapping_operation.name, "UNWRAP") val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) span.setAttribute( - AttributeName.secret_key_wrapping_cipher.name, - cipherParamsSpec.toString() + AttributeName.secret_key_wrapping_transformation.name, + cipherParamsSpec.transformation ) Logger.info(methodTag, "Unwrapping secret key with cipher spec: $cipherParamsSpec") val key = AndroidKeyStoreUtil.unwrap( @@ -379,8 +379,16 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( "Key pair generated successfully with spec: $spec" ) span.setAttribute( - AttributeName.key_pair_gen_successful_method.name, - spec.toString() + AttributeName.key_pair_gen_description.name, + spec.description + ) + span.setAttribute( + AttributeName.key_pair_gen_algorithm.name, + spec.algorithm + ) + span.setAttribute( + AttributeName.key_pair_gen_encryptionPaddings.name, + spec.encryptionPaddings.toString() ) span.setStatus(StatusCode.OK) return@use keyPair diff --git a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java index da097c5a15..f937589afb 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java +++ b/common4j/src/main/com/microsoft/identity/common/java/opentelemetry/AttributeName.java @@ -325,6 +325,21 @@ public enum AttributeName { */ key_pair_gen_successful_method, + /** + * Describes the keypair generation operation. + */ + key_pair_gen_description, + + /** + * Indicates the algorithm used to generate a keypair. + */ + key_pair_gen_algorithm, + + /** + * Indicates the encryption paddings used to generate a keypair. + */ + key_pair_gen_encryptionPaddings, + /** * Indicates the exception in generating a keypair. */ @@ -366,9 +381,9 @@ public enum AttributeName { is_switch_browser_resume_handled, /** - * Records the Cipher used to encrypt/decrypt the keypair. + * Records the Cipher transformation used to encrypt/decrypt the keypair. */ - secret_key_wrapping_cipher, + secret_key_wrapping_transformation, /** * Records the operation used to wrap/unwrap the secret key. diff --git a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java index c33e292848..e22c7781a1 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java +++ b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java @@ -27,11 +27,10 @@ import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.Files; import edu.umd.cs.findbugs.annotations.Nullable; import lombok.NonNull; @@ -53,17 +52,13 @@ private FileUtil() { * @param data a data blob to be written. * @param file file to write to. */ - public static void writeDataToFile(@NonNull final byte[] data, + public static void writeDataToFile(final byte @NonNull [] data, @NonNull final File file) throws ClientException { final String methodName = ":writeKeyData"; - try { - final OutputStream out = new FileOutputStream(file); - try { - out.write(data); - } finally { - out.close(); - } + + try (OutputStream out = Files.newOutputStream(file.toPath())) { + out.write(data); } catch (IOException e) { final ClientException clientException = new ClientException( IO_ERROR, @@ -97,20 +92,16 @@ public static byte[] readFromFile(@NonNull final File file, return null; } - try { - final InputStream in = new FileInputStream(file); - - try { - final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - final byte[] buffer = new byte[dataSize]; - int count; - while ((count = in.read(buffer)) != -1) { - bytes.write(buffer, 0, count); - } - return bytes.toByteArray(); - } finally { - in.close(); + + + try (InputStream in = Files.newInputStream(file.toPath())) { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final byte[] buffer = new byte[dataSize]; + int count; + while ((count = in.read(buffer)) != -1) { + bytes.write(buffer, 0, count); } + return bytes.toByteArray(); } catch (IOException e) { final ClientException clientException = new ClientException( IO_ERROR, From 0372b4f40e165948f26578f8bc77f5894c36c3d9 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 11 Aug 2025 17:03:34 -0700 Subject: [PATCH 32/51] feat: Introduce WrappedSecretKey class and implement new key storage format --- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 71 +++-- .../common/crypto/WrappedSecretKey.kt | 201 ++++++++++++ .../common/crypto/WrappedSecretKeyTest.kt | 295 ++++++++++++++++++ .../common/java/flighting/CommonFlight.java | 7 +- 4 files changed, 545 insertions(+), 29 deletions(-) create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt create mode 100644 common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index d714ee7510..870221110e 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -51,11 +51,14 @@ import javax.crypto.SecretKey * @param filePath The file path where the wrapped secret key is stored. * @param context The context used to access the Android KeyStore and file system. */ -class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( +class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( context: Context, override val alias: String, private val filePath: String, - private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory(context, alias) + private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory( + context, + alias + ) ) : ISecretKeyProvider { companion object { private const val TAG = "KeyStoreBackedSecretKeyProvider" @@ -194,8 +197,12 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( Logger.info(methodTag, "No existing keypair found. Generating a new one.") generateKeyPair() } - val keyWrapped = wrapSecretKey(newSecretKey, keyPair) - FileUtil.writeDataToFile(keyWrapped, keyFile) + val (keyWrapped, cipher) = wrapSecretKey(newSecretKey, keyPair) + WrappedSecretKey( + byteArray = keyWrapped, + algorithm = AES256SecretKeyGenerator.AES_ALGORITHM, + cipherTransformation = cipher.transformation + ).storeOnFile(keyFile) return newSecretKey } @@ -216,7 +223,7 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( return null } - val wrappedSecretKey = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) + val wrappedSecretKey = WrappedSecretKey.loadFromFile(keyFile, KEY_FILE_SIZE) if (wrappedSecretKey == null) { Logger.warn(methodTag, "Key file is empty") // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache @@ -241,7 +248,7 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( private fun wrapSecretKey( secretKey: SecretKey, keyPair: KeyPair - ): ByteArray { + ): Pair { val methodTag = "$TAG:wrapSecretKey" val span = OTelUtility.createSpanFromParent( SpanName.SecretKeyWrapping.name, @@ -251,7 +258,11 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( return try { SpanExtension.makeCurrentSpan(span).use { _ -> span.setAttribute(AttributeName.secret_key_wrapping_operation.name, "WRAP") - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + val cipherParamsSpec = getKeyPairCompatibleCipherSpecs(keyPair).firstOrNull() + ?: throw ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "No compatible cipher specs found for key pair: $keyPair" + ) span.setAttribute( AttributeName.secret_key_wrapping_transformation.name, cipherParamsSpec.transformation @@ -264,7 +275,7 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( cipherParamsSpec.algorithmParameterSpec ) span.setStatus(StatusCode.OK) - wrappedKey + wrappedKey to cipherParamsSpec } } catch (exception: Exception) { Logger.error(methodTag, "Failed to wrap secret key", exception) @@ -277,7 +288,7 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( } private fun unwrapSecretKey( - wrappedSecretKey: ByteArray, + wrappedSecretKey: WrappedSecretKey, keyPair: KeyPair ): SecretKey { val methodTag = "$TAG:unwrapSecretKey" @@ -289,15 +300,21 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( return try { SpanExtension.makeCurrentSpan(span).use { _ -> span.setAttribute(AttributeName.secret_key_wrapping_operation.name, "UNWRAP") - val cipherParamsSpec = selectCompatibleCipherSpec(keyPair) + val cipherParamsSpec = getKeyPairCompatibleCipherSpecs(keyPair).firstOrNull { spec -> + spec.transformation.contains(wrappedSecretKey.cipherTransformation, ignoreCase = true) + } ?: throw ClientException( + ClientException.UNKNOWN_CRYPTO_ERROR, + "No compatible cipher specs found for key pair: $keyPair" + ) + span.setAttribute( AttributeName.secret_key_wrapping_transformation.name, cipherParamsSpec.transformation ) Logger.info(methodTag, "Unwrapping secret key with cipher spec: $cipherParamsSpec") val key = AndroidKeyStoreUtil.unwrap( - wrappedSecretKey, - AES256SecretKeyGenerator.AES_ALGORITHM, + wrappedSecretKey.byteArray, + wrappedSecretKey.algorithm, keyPair, cipherParamsSpec.transformation, cipherParamsSpec.algorithmParameterSpec @@ -316,16 +333,18 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( } /** - * Selects the most compatible cipher specification for the given key pair. + * Get all compatible cipher specifications for the given key pair in priority order. * * Matches key pair's supported encryption paddings with available cipher specs, - * prioritizing more secure options first. Falls back to PKCS1 if no match found. + * returning all compatible specs prioritized by security (most secure first). + * Returns an empty list if no compatible specs are found. * - * @param keyPair The key pair to find compatible cipher spec for - * @return Compatible [CipherSpec] or PKCS1 fallback + * @param keyPair The key pair to find compatible cipher specs for + * @return List of compatible [CipherSpec] ordered by priority (most secure first) */ - private fun selectCompatibleCipherSpec(keyPair: KeyPair): CipherSpec { - val methodTag = "$TAG:selectCompatibleCipherSpec" + @Throws(ClientException::class) + private fun getKeyPairCompatibleCipherSpecs(keyPair: KeyPair): List { + val methodTag = "$TAG:selectCompatibleCipherSpecs" val supportedPaddings = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(keyPair) val availableCipherSpecs = cryptoParameterSpecFactory.getPrioritizedCipherParameterSpecs() Logger.verbose( @@ -333,19 +352,16 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( "Supported paddings by the keyPair: $supportedPaddings" + ",Specs available in order of priority: $availableCipherSpecs" ) - for (cipherSpec in availableCipherSpecs) { - for (padding in supportedPaddings) { - if (cipherSpec.padding.contains(padding, ignoreCase = true)) { - Logger.info(methodTag, "Selected cipher spec: $cipherSpec") - return cipherSpec - } + val compatibleSpecs = availableCipherSpecs.filter { spec -> + supportedPaddings.any { padding -> + spec.padding.contains(padding, ignoreCase = true) } } - Logger.warn(methodTag, "No supported cipher specification found for wrapping the key.") - // Fallback to PKCS#1 padding if no compatible spec is found, instead of throwing an error. - return CipherSpec.pkcs1CipherSpec + Logger.verbose(methodTag, "Found ${compatibleSpecs.size} compatible cipher specs: $compatibleSpecs") + return compatibleSpecs } + /** * Generates a new RSA key pair using prioritized specifications with fallback support. * @@ -486,4 +502,3 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( throw ExceptionAdapter.clientExceptionFromException(finalError) } } - diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt new file mode 100644 index 0000000000..3eb77c3b66 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.java.flighting.CommonFlight +import com.microsoft.identity.common.java.flighting.CommonFlightsManager +import com.microsoft.identity.common.java.util.FileUtil +import com.microsoft.identity.common.logging.Logger +import org.json.JSONObject +import java.io.File +import java.nio.ByteBuffer + +class WrappedSecretKey( + val byteArray: ByteArray, + val algorithm: String, + val cipherTransformation: String +) { + + /** + * Stores the wrapped secret key to file. + * + * @param file The file to store the key data + */ + fun storeOnFile(file: File) { + val methodTag = "WrappedSecretKey:storeOnFile" + try { + val useNewFormat = + CommonFlightsManager + .getFlightsProvider() + .isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) + + if (useNewFormat) { + // New format: Store metadata header + raw key data + val metadata = JSONObject().apply { + put("algorithm", algorithm) + put("cipherTransformation", cipherTransformation) + put("version", NEW_FORMAT_VERSION) + put("keyDataLength", byteArray.size) + } + val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) + + // Use ByteBuffer for cleaner header writing + val output = ByteBuffer.allocate(4 + metadataBytes.size + byteArray.size) + .putInt(metadataBytes.size) // Write header length (4 bytes, big-endian) + .put(metadataBytes) // Write metadata + .put(byteArray) // Write raw key data + .array() + + FileUtil.writeDataToFile(output, file) + Logger.info(methodTag, "Key successfully stored on disk using optimized new format.") + } else { + // Old format: Store only raw key bytes + FileUtil.writeDataToFile(byteArray, file) + Logger.info(methodTag, "Key successfully stored on disk using old format.") + } + } catch (e: Exception) { + Logger.error(methodTag, "Failed to store key on disk", e) + throw ClientException(ClientException.IO_ERROR, "Failed to store key on disk", e) + } + } + + companion object { + private const val TAG = "WrappedSecretKey" + private const val NEW_FORMAT_VERSION = "1.0" + private const val DEFAULT_ALGORITHM = "AES" + private const val DEFAULT_CIPHER_TRANSFORMATION = "RSA/ECB/PKCS1Padding" + + /** + * Loads a wrapped secret key from file, automatically detecting the storage format. + * + * @param file The file containing the wrapped key data + * @param fileSize Maximum size to read from the file + * @return WrappedSecretKey instance or null if file doesn't exist or is empty + */ + fun loadFromFile(file: File, fileSize: Int): WrappedSecretKey? { + val methodTag = "$TAG:loadFromFile" + + if (!file.exists()) { + Logger.warn(methodTag, "Key file does not exist") + return null + } + + try { + val rawData = FileUtil.readFromFile(file, fileSize) + if (rawData == null || rawData.isEmpty()) { + Logger.warn(methodTag, "Key file is empty") + return null + } + + return if (isNewFormat(rawData)) { + loadFromNewFormat(rawData) + } else { + loadFromOldFormat(rawData) + } + } catch (e: Exception) { + Logger.error(methodTag, "Failed to load key from file", e) + throw ClientException(ClientException.IO_ERROR, "Failed to load key from file", e) + } + } + + /** + * Determines if the raw data is in new binary format or old binary format. + * + * @param rawData The raw bytes read from file + * @return true if data is in new format, false if old format + */ + private fun isNewFormat(rawData: ByteArray): Boolean { + return try { + // New format starts with 4-byte header length, followed by JSON metadata + if (rawData.size < 4) return false + + val buffer = ByteBuffer.wrap(rawData) + val headerLength = buffer.getInt() + + // Sanity check: header length should be reasonable + if (headerLength < 10 || headerLength > rawData.size - 4) return false + + // Try to parse the metadata JSON + val metadataBytes = ByteArray(headerLength) + buffer.get(metadataBytes) + val jsonString = String(metadataBytes, Charsets.UTF_8) + val json = JSONObject(jsonString) + + // Check if it has the expected new format fields + json.has("algorithm") && json.has("cipherTransformation") && json.has("version") + } catch (e: Exception) { + // If parsing fails, assume it's old format + false + } + } + + /** + * Loads wrapped secret key from new binary format. + * + * @param rawData The raw binary data + * @return WrappedSecretKey instance + */ + private fun loadFromNewFormat(rawData: ByteArray): WrappedSecretKey { + val methodTag = "$TAG:loadFromNewFormat" + Logger.info(methodTag, "Loading key using optimized new binary format") + + val buffer = ByteBuffer.wrap(rawData) + val headerLength = buffer.getInt() + + // Extract and parse metadata + val metadataBytes = ByteArray(headerLength) + buffer.get(metadataBytes) + val jsonString = String(metadataBytes, Charsets.UTF_8) + val json = JSONObject(jsonString) + + val algorithm = json.getString("algorithm") + val cipherTransformation = json.getString("cipherTransformation") + val keyDataLength = json.optInt("keyDataLength", buffer.remaining()) + + // Extract raw key data + val keyBytes = ByteArray(keyDataLength) + buffer.get(keyBytes) + + return WrappedSecretKey(keyBytes, algorithm, cipherTransformation) + } + + /** + * Loads wrapped secret key from old binary format. + * Uses default values for algorithm and cipher transformation. + * + * @param rawData The raw key bytes + * @return WrappedSecretKey instance with default algorithm and cipher transformation + */ + private fun loadFromOldFormat(rawData: ByteArray): WrappedSecretKey { + val methodTag = "$TAG:loadFromOldFormat" + Logger.info(methodTag, "Loading key using old format with default algorithm and cipher transformation") + + return WrappedSecretKey( + byteArray = rawData, + algorithm = DEFAULT_ALGORITHM, + cipherTransformation = DEFAULT_CIPHER_TRANSFORMATION + ) + } + } +} diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt new file mode 100644 index 0000000000..3799cec79b --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto + +import com.microsoft.identity.common.java.flighting.CommonFlight +import com.microsoft.identity.common.java.flighting.CommonFlightsManager +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.ByteBuffer + +class WrappedSecretKeyTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private val testKeyBytes = "test-key-data-12345".toByteArray() + private val testAlgorithm = "AES" + private val testCipherTransformation = "RSA/ECB/PKCS1Padding" + + @Before + fun setUp() { + mockkObject(CommonFlightsManager) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun constructorCreatesWrappedSecretKeyWithCorrectProperties() { + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + assertArrayEquals(testKeyBytes, wrappedKey.byteArray) + assertEquals(testAlgorithm, wrappedKey.algorithm) + assertEquals(testCipherTransformation, wrappedKey.cipherTransformation) + } + + @Test + fun loadFromFileReturnsNullWhenFileDoesNotExist() { + val nonExistentFile = File("non-existent-file.dat") + + val result = WrappedSecretKey.loadFromFile(nonExistentFile, 1024) + + assertNull(result) + } + + @Test + fun loadFromFileReturnsNullWhenFileIsEmpty() { + val testFile = tempFolder.newFile("empty-file.dat") + + val result = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNull(result) + } + + @Test + fun roundTripStoreAndLoadWithNewFormatPreservesData() { + val testFile = tempFolder.newFile("test-new-format.dat") + val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Mock flight to enable new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + // Store with new format (controlled by flight) + originalKey.storeOnFile(testFile) + + // Load back + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNotNull(loadedKey) + assertArrayEquals(originalKey.byteArray, loadedKey!!.byteArray) + assertEquals(originalKey.algorithm, loadedKey.algorithm) + assertEquals(originalKey.cipherTransformation, loadedKey.cipherTransformation) + } + + @Test + fun roundTripStoreAndLoadWithOldFormatPreservesKeyData() { + val testFile = tempFolder.newFile("test-old-format.dat") + val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Mock flight to disable new format (use old format) + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + + // Store with old format (controlled by flight) + originalKey.storeOnFile(testFile) + + // Load back (should use default values for algorithm and cipher) + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNotNull(loadedKey) + assertArrayEquals(originalKey.byteArray, loadedKey!!.byteArray) + assertEquals("AES", loadedKey.algorithm) // Default value + assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) // Default value + } + + @Test + fun newFormatStoresLargerFileThanOldFormat() { + val newFormatFile = tempFolder.newFile("new-format.dat") + val oldFormatFile = tempFolder.newFile("old-format.dat") + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Test new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + wrappedKey.storeOnFile(newFormatFile) + + // Test old format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + wrappedKey.storeOnFile(oldFormatFile) + + assertTrue("New format should create larger file due to metadata", + newFormatFile.length() > oldFormatFile.length()) + assertEquals("Old format should only contain key bytes", + testKeyBytes.size.toLong(), oldFormatFile.length()) + } + + @Test + fun newFormatFileContainsValidBinaryStructure() { + val testFile = tempFolder.newFile("binary-structure.dat") + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Mock flight to enable new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + wrappedKey.storeOnFile(testFile) + + // Read and verify the binary structure + val fileBytes = testFile.readBytes() + val buffer = ByteBuffer.wrap(fileBytes) + + val headerLength = buffer.getInt() + assertTrue("Header length should be reasonable", headerLength > 0 && headerLength < fileBytes.size) + + val metadataBytes = ByteArray(headerLength) + buffer.get(metadataBytes) + + val metadata = JSONObject(String(metadataBytes, Charsets.UTF_8)) + assertEquals(testAlgorithm, metadata.getString("algorithm")) + assertEquals(testCipherTransformation, metadata.getString("cipherTransformation")) + assertEquals("1.0", metadata.getString("version")) + assertEquals(testKeyBytes.size, metadata.getInt("keyDataLength")) + + val remainingKeyData = ByteArray(buffer.remaining()) + buffer.get(remainingKeyData) + assertArrayEquals(testKeyBytes, remainingKeyData) + } + + @Test + fun largeKeyDataIsHandledCorrectly() { + val testFile = tempFolder.newFile("large-key.dat") + val largeKeyData = ByteArray(8192) { it.toByte() } // 8KB test data + val wrappedKey = WrappedSecretKey(largeKeyData, testAlgorithm, testCipherTransformation) + + // Mock flight to enable new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + // Store and load + wrappedKey.storeOnFile(testFile) + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 10240) + + assertNotNull(loadedKey) + assertArrayEquals(largeKeyData, loadedKey!!.byteArray) + assertEquals(testAlgorithm, loadedKey.algorithm) + assertEquals(testCipherTransformation, loadedKey.cipherTransformation) + } + + @Test + fun oldFormatDetectionWorksWithBinaryData() { + val testFile = tempFolder.newFile("binary-data.dat") + val binaryData = ByteArray(256) { (it % 256).toByte() } // Random binary data + + // Write raw binary data (simulating old format) + testFile.writeBytes(binaryData) + + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNotNull(loadedKey) + assertArrayEquals(binaryData, loadedKey!!.byteArray) + assertEquals("AES", loadedKey.algorithm) + assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + } + + @Test + fun newFormatWithMissingKeyDataLengthUsesFallback() { + val testFile = tempFolder.newFile("missing-length.dat") + + // Create metadata without keyDataLength + val metadata = JSONObject().apply { + put("algorithm", testAlgorithm) + put("cipherTransformation", testCipherTransformation) + put("version", "1.0") + // Intentionally omit keyDataLength + } + val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) + + // Write binary format manually + val buffer = ByteBuffer.allocate(4 + metadataBytes.size + testKeyBytes.size) + .putInt(metadataBytes.size) + .put(metadataBytes) + .put(testKeyBytes) + testFile.writeBytes(buffer.array()) + + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNotNull(loadedKey) + assertArrayEquals(testKeyBytes, loadedKey!!.byteArray) + assertEquals(testAlgorithm, loadedKey.algorithm) + assertEquals(testCipherTransformation, loadedKey.cipherTransformation) + } + + @Test + fun corruptedNewFormatFallsBackToOldFormat() { + val testFile = tempFolder.newFile("corrupted.dat") + + // Create invalid new format data + val invalidData = ByteBuffer.allocate(20) + .putInt(100) // Header length larger than available data + .put("invalid".toByteArray()) + .array() + testFile.writeBytes(invalidData) + + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNotNull(loadedKey) + assertArrayEquals(invalidData, loadedKey!!.byteArray) // Should load as raw bytes + assertEquals("AES", loadedKey.algorithm) + assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + } + + @Test + fun storeOnFileUsesOldFormatWhenFlightDisabled() { + val testFile = tempFolder.newFile("flight-disabled.dat") + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Mock flight to disable new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + + wrappedKey.storeOnFile(testFile) + + // Verify old format: file should only contain raw key bytes + val fileBytes = testFile.readBytes() + assertArrayEquals(testKeyBytes, fileBytes) + } + + @Test + fun storeOnFileUsesNewFormatWhenFlightEnabled() { + val testFile = tempFolder.newFile("flight-enabled.dat") + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Mock flight to enable new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + wrappedKey.storeOnFile(testFile) + + // Verify new format: file should be larger than just key bytes + val fileBytes = testFile.readBytes() + assertTrue("New format file should be larger than raw key", fileBytes.size > testKeyBytes.size) + + // Verify it can be loaded correctly + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + assertNotNull(loadedKey) + assertArrayEquals(testKeyBytes, loadedKey!!.byteArray) + assertEquals(testAlgorithm, loadedKey.algorithm) + assertEquals(testCipherTransformation, loadedKey.cipherTransformation) + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index c2cf8539a2..8d9f8a526c 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -127,10 +127,15 @@ public enum CommonFlight implements IFlightConfig { ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING("EnableOAEPWithSHAAndMGF1Padding", false), /** - * Flight to enable the OAEP Android Wrapped Key Loader. + * Flight to enable the new KEK algorithm for encryption/decryption of keys. */ ENABLE_KEYSTORE_BACKED_SECRET_KEY_PROVIDER("EnableKeyStoreBackedSecretKeyProvider", false), + /** + * Flight to enable the new wrapped secret key format. + */ + ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT("EnableNewWrappedSecretKeyFormat", false), + /** * Flight to enable handling the UI in edge to edge mode */ From f5e8b02db2dceff7b4f7345a8f2b42be10668cf5 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 12 Aug 2025 09:17:37 -0700 Subject: [PATCH 33/51] API level 19 is not available --- .../common/internal/util/AndroidKeyStoreUtilTest.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java b/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java index bc4117628f..4ef2ad83c3 100644 --- a/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java +++ b/common/src/test/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtilTest.java @@ -497,17 +497,6 @@ public void testGetKeyPairEncryptionPaddings_LegacyAPI_ReturnsEmptyList() { assertTrue(result.isEmpty()); } - @Test - @Config(sdk = Build.VERSION_CODES.KITKAT) // API 19, before M - public void testGetKeyPairEncryptionPaddings_VeryLegacyAPI_ReturnsEmptyList() { - // Act - Call the REAL method on very legacy API - List result = AndroidKeyStoreUtil.getKeyPairEncryptionPaddings(mockKeyPair); - - // Assert - Should return empty list because API < 23 - assertNotNull(result); - assertTrue(result.isEmpty()); - } - @Test @Config(sdk = Build.VERSION_CODES.M) // API 23 public void testGetKeyPairEncryptionPaddings_ModernAPI_PaddingsWithoutSuffix_ReturnedAsIs() throws Exception { From 6482078ba6af346ecf9b423bda785b0e77786e92 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 19 Aug 2025 20:40:20 -0700 Subject: [PATCH 34/51] feat: Enhance key storage and retrieval with backward and forward compatibility tests for new wrapped secret key format --- ...ckedSecretKeyProviderInstrumentedTest.java | 110 ++--- ...oreBackedSecretKeyProviderRolloutTest.java | 385 ++++++++++++++++++ .../crypto/KeyStoreBackedSecretKeyProvider.kt | 13 +- .../common/crypto/WrappedSecretKeyTest.kt | 97 +++++ .../identity/common/java/util/FileUtil.java | 37 +- 5 files changed, 542 insertions(+), 100 deletions(-) create mode 100644 common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java index 06b6cae9ed..776d4c5416 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderInstrumentedTest.java @@ -26,6 +26,7 @@ import android.content.Context; +import androidx.annotation.NonNull; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -34,10 +35,13 @@ import com.microsoft.identity.common.java.crypto.key.KeyUtil; import com.microsoft.identity.common.java.exception.ClientException; import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.IFlightsManager; import com.microsoft.identity.common.java.flighting.IFlightsProvider; import com.microsoft.identity.common.java.util.FileUtil; import com.microsoft.identity.common.logging.Logger; +import org.jetbrains.annotations.NotNull; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -78,7 +82,6 @@ public void setUp() throws Exception { AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); FileUtil.deleteFile(getKeyFile()); - final Context mockContext = Mockito.mock(Context.class); final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class); Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) .thenReturn(true); @@ -86,43 +89,37 @@ public void setUp() throws Exception { .thenReturn(true); Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) .thenReturn(false); - final CryptoParameterSpecFactory cryptoParameterSpecFactory = new CryptoParameterSpecFactory( - mockContext, - MOCK_KEY_ALIAS, - mockFlightsProvider - ); - keyProvider = new KeyStoreBackedSecretKeyProvider( - context, - MOCK_KEY_ALIAS, - MOCK_KEY_FILE_PATH, - cryptoParameterSpecFactory - ); - } - - private KeyStoreBackedSecretKeyProvider getOAEPKeyProvider() { - // Clean slate for each test - final Context mockContext = Mockito.mock(Context.class); - final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class); - Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) - .thenReturn(true); - Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) - .thenReturn(true); - Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) - .thenReturn(true); - final CryptoParameterSpecFactory cryptoParameterSpecFactory = new CryptoParameterSpecFactory( - mockContext, - MOCK_KEY_ALIAS, - mockFlightsProvider - ); - return new KeyStoreBackedSecretKeyProvider( + // Create anonymous IFlightsManager + IFlightsManager anonymousFlightsManager = new IFlightsManager() { + @Override + public @NotNull IFlightsProvider getFlightsProvider(long waitForConfigsWithTimeoutInMs) { + return mockFlightsProvider; + } + @Override + public @NotNull IFlightsProvider getFlightsProviderForTenant(@NotNull String tenantId, long waitForConfigsWithTimeoutInMs) { + return mockFlightsProvider; + } + @Override + public @NotNull IFlightsProvider getFlightsProviderForTenant(@NotNull String tenantId) { + return mockFlightsProvider; + } + @NonNull + @Override + public IFlightsProvider getFlightsProvider() { + return mockFlightsProvider; + } + }; + + // Initialize CommonFlightsManager with the anonymous implementation + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(anonymousFlightsManager); + keyProvider = new KeyStoreBackedSecretKeyProvider( context, MOCK_KEY_ALIAS, - MOCK_KEY_FILE_PATH, - cryptoParameterSpecFactory + MOCK_KEY_FILE_PATH ); } - + @After public void tearDown() throws Exception { // Clean up after each test @@ -426,51 +423,4 @@ public void testMigrationScenario_RollbackToAndroidWrappedKeyProvider() throws C Assert.assertEquals("Thumbprints should match after rollback", originalThumbprint, rolledBackThumbprint); } - - // Compatibility Tests KeyStoreBackedSecretKeyProvider using PKCS1 and OAEP providers - - @Test - public void testBackwardCompatibility_PKCS1ToOAEP_KeyCreation() throws ClientException { - // Test that a key created with PKCS1 provider can be read by OAEP provider - - // Step 1: Create key with PKCS1 provider (regular keyProvider) - SecretKey pkcs1Key = keyProvider.generateNewSecretKey(); - Assert.assertNotNull("PKCS1 provider key should not be null", pkcs1Key); - String pkcs1Thumbprint = KeyUtil.getKeyThumbPrint(pkcs1Key); - - // Step 2: Try to read the same key with OAEP provider - KeyStoreBackedSecretKeyProvider oaepProvider = getOAEPKeyProvider(); - SecretKey oaepReadKey = oaepProvider.readSecretKeyFromStorage(); - - Assert.assertNotNull("OAEP provider should read PKCS1 created key", oaepReadKey); - String oaepThumbprint = KeyUtil.getKeyThumbPrint(oaepReadKey); - Assert.assertEquals("Thumbprints should match between PKCS1 and OAEP", pkcs1Thumbprint, oaepThumbprint); - - // Step 3: Verify both providers use same key type identifier and cipher transformation - Assert.assertEquals("Key type identifiers should match", - keyProvider.getKeyTypeIdentifier(), oaepProvider.getKeyTypeIdentifier()); - Assert.assertEquals("Cipher transformations should match", - keyProvider.getCipherTransformation(), oaepProvider.getCipherTransformation()); - } - - @Test - public void testBackwardCompatibility_OAEPToPKCS1_CachedKey() throws ClientException { - // Test cached key compatibility between OAEP and PKCS1 providers - - // Step 1: Generate and cache key with OAEP provider - KeyStoreBackedSecretKeyProvider oaepProvider = getOAEPKeyProvider(); - SecretKey oaepKey = oaepProvider.getKey(); - Assert.assertNotNull("OAEP cached key should not be null", oaepProvider.getKeyFromCache()); - - keyProvider.clearKeyFromCache(); - // Step 2: PKCS1 provider should be able to read the same key - SecretKey pkcs1Key = keyProvider.getKey(); - - Assert.assertNotNull("PKCS1 provider should get key", pkcs1Key); - - // Step 3: Keys should have same thumbprint - String oaepThumbprint = KeyUtil.getKeyThumbPrint(oaepKey); - String pkcs1Thumbprint = KeyUtil.getKeyThumbPrint(pkcs1Key); - Assert.assertEquals("Cached keys should have same thumbprint", oaepThumbprint, pkcs1Thumbprint); - } } \ No newline at end of file diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java new file mode 100644 index 0000000000..d35c2141ff --- /dev/null +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.microsoft.identity.common.adal.internal.AuthenticationSettings; +import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil; +import com.microsoft.identity.common.java.crypto.key.KeyUtil; +import com.microsoft.identity.common.java.exception.ClientException; +import com.microsoft.identity.common.java.flighting.CommonFlight; +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; +import com.microsoft.identity.common.java.flighting.IFlightsManager; +import com.microsoft.identity.common.java.flighting.IFlightsProvider; +import com.microsoft.identity.common.java.util.FileUtil; +import com.microsoft.identity.common.logging.Logger; + +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import java.io.File; + +import javax.crypto.SecretKey; + +/** + * Instrumented tests for KeyStoreBackedSecretKeyProvider. + * These tests run on real Android devices/emulators with access to the Android KeyStore. + */ +@RunWith(AndroidJUnit4.class) +public class KeyStoreBackedSecretKeyProviderRolloutTest { + + private final Context context = ApplicationProvider.getApplicationContext(); + private final String MOCK_KEY_ALIAS = "MOCK_KEY_ALIAS_INSTRUMENTED"; + private final String MOCK_KEY_FILE_PATH = "MOCK_KEY_FILE_PATH_INSTRUMENTED"; + + @BeforeClass + public static void classSetUp() { + Logger.setAndroidLogger(); + Logger.setAllowLogcat(true); + } + + @Before + public void setUp() throws Exception { + // Clean slate for each test + AuthenticationSettings.INSTANCE.clearSecretKeysForTestCases(); + AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); + FileUtil.deleteFile(getKeyFile()); + } + + /** + * Creates a Phase 1 provider configuration for backward compatibility testing. + *

+ * Phase 1 represents the baseline configuration with both new format flights disabled: + * - ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING = false (uses PKCS1 padding) + * - ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT = false (uses legacy format without metadata) + *

+ * This configuration is used to test scenarios where applications are running + * with the original crypto implementation before any flight rollouts. + * + */ + private void enablePhase1Flights() { + configureFlights(false, false); + } + + /** + * Creates a Phase 2 provider configuration for intermediate rollout testing. + *

+ * Phase 2 represents the intermediate configuration where only the new wrapped + * secret key format flight is enabled: + * - ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING = false (still uses PKCS1 padding) + * - ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT = true (uses new format with metadata) + *

+ * This configuration is used to test scenarios where the new wrapped secret key + * format has been rolled out but OAEP padding has not yet been enabled. + * This allows testing backward compatibility with Phase 1 and forward compatibility + * with Phase 3. + * + */ + private void enablePhase2Flights() { + configureFlights(false, true); + } + + /** + * Creates a Phase 3 provider configuration for full feature rollout testing. + *

+ * Phase 3 represents the final configuration with both flights enabled: + * - ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING = true (uses OAEP with SHA and MGF1 padding) + * - ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT = true (uses new format with metadata) + *

+ * This configuration is used to test scenarios where all crypto enhancements + * have been fully rolled out. It represents the target state for maximum + * security and functionality. + * + */ + private void enablePhase3Flights() { + configureFlights(true, true); + } + + /** + * Helper method to configure flights with specified settings. + * This method reduces code duplication across the enablePhase methods. + * + * @param enableOAEPWithSHAAndMGF1Padding whether to enable OAEP with SHA and MGF1 padding + * @param enableNewWrappedSecretKeyFormat whether to enable new wrapped secret key format + */ + private void configureFlights(final boolean enableOAEPWithSHAAndMGF1Padding, + final boolean enableNewWrappedSecretKeyFormat) { + final IFlightsProvider mockFlightsProvider = createFlightsProvider( + enableOAEPWithSHAAndMGF1Padding, + enableNewWrappedSecretKeyFormat); + final IFlightsManager flightsManager = createFlightsManager(mockFlightsProvider); + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(flightsManager); + } + + /** + * Creates a mock flights provider with the specified flight configurations. + * + * @param enableOAEPWithSHAAndMGF1Padding whether to enable OAEP with SHA and MGF1 padding + * @param enableNewWrappedSecretKeyFormat whether to enable new wrapped secret key format + * @return configured mock IFlightsProvider + */ + private IFlightsProvider createFlightsProvider(final boolean enableOAEPWithSHAAndMGF1Padding, + final boolean enableNewWrappedSecretKeyFormat) { + final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class); + + // These flights are always enabled for key generation spec improvements + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) + .thenReturn(true); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) + .thenReturn(true); + + // Configure the phase-specific flights + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) + .thenReturn(enableOAEPWithSHAAndMGF1Padding); + Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT)) + .thenReturn(enableNewWrappedSecretKeyFormat); + + return mockFlightsProvider; + } + + /** + * Creates a mock flights manager with the given flights provider. + * + * @param flightsProvider the flights provider to use + * @return configured mock IFlightsManager + */ + private IFlightsManager createFlightsManager(final IFlightsProvider flightsProvider) { + return new IFlightsManager() { + @Override + public @NotNull IFlightsProvider getFlightsProvider(long waitForConfigsWithTimeoutInMs) { + return flightsProvider; + } + + @Override + public @NotNull IFlightsProvider getFlightsProviderForTenant(@NotNull String tenantId, long waitForConfigsWithTimeoutInMs) { + return flightsProvider; + } + + @Override + public @NotNull IFlightsProvider getFlightsProviderForTenant(@NotNull String tenantId) { + return flightsProvider; + } + + @NonNull + @Override + public IFlightsProvider getFlightsProvider() { + return flightsProvider; + } + }; + } + + @After + public void tearDown() throws Exception { + AndroidKeyStoreUtil.deleteKey(MOCK_KEY_ALIAS); + FileUtil.deleteFile(getKeyFile()); + } + + /** + * Gets the file path for the secret key storage file. + * This file is used to persist wrapped secret keys to disk. + * + * @return File object representing the key storage file location + */ + private File getKeyFile() { + return new File( + context.getDir(context.getPackageName(), Context.MODE_PRIVATE), + MOCK_KEY_FILE_PATH + ); + } + + /** + * Creates a KeyStoreBackedSecretKeyProvider instance with the standard test configuration. + * This helper method eliminates code duplication across test methods. + * + * @return a new KeyStoreBackedSecretKeyProvider instance configured for testing + */ + private KeyStoreBackedSecretKeyProvider createKeyProvider() { + return new KeyStoreBackedSecretKeyProvider( + context, + MOCK_KEY_ALIAS, + MOCK_KEY_FILE_PATH + ); + } + + private void validateFileIsLegacyFormat() { + final File keyFile = getKeyFile(); + Assert.assertTrue("Key file should exist after generation", keyFile.exists()); + final long fileSize = keyFile.length(); + Assert.assertEquals( 256, fileSize); + } + + private void validateFileINewFormat() { + final File keyFile = getKeyFile(); + Assert.assertTrue("Key file should exist after generation", keyFile.exists()); + final long fileSize = keyFile.length(); + Assert.assertTrue(fileSize > (long) 256); + } + + /** + * Validates that two keys are equivalent by comparing their properties. + * + * @param originalKey the original key + * @param readKey the key read from storage + * @param testDescription description for assertion messages + */ + private void validateKeyEquivalence( + final SecretKey originalKey, + final SecretKey readKey, + final String testDescription) { + Assert.assertNotNull(testDescription + " key should not be null", readKey); + + final String originalThumbprint = KeyUtil.getKeyThumbPrint(originalKey); + final String readThumbprint = KeyUtil.getKeyThumbPrint(readKey); + + Assert.assertEquals("Thumbprints should match for " + testDescription, + originalThumbprint, readThumbprint); + Assert.assertEquals("Keys should be same instance for " + testDescription, + originalKey, readKey); + + } + + /** + * Validates key file metadata contains or doesn't contain specific algorithm indicators. + * + * @param shouldContain algorithm string that should be present + * @param shouldNotContain algorithm string that should not be present + * @param testDescription description for assertion messages + */ + private void validateKeyMetadata(String shouldContain, String shouldNotContain, String testDescription) { + final File keyFile = getKeyFile(); + try { + byte[] keyFileContent = java.nio.file.Files.readAllBytes(keyFile.toPath()); + String keyFileString = new String(keyFileContent, java.nio.charset.StandardCharsets.UTF_8); + + if (shouldContain != null) { + Assert.assertTrue(testDescription + " metadata should contain " + shouldContain, + keyFileString.contains(shouldContain)); + } + + if (shouldNotContain != null) { + Assert.assertFalse(testDescription + " metadata should not contain " + shouldNotContain, keyFileString.contains(shouldNotContain)); + } + } catch (java.io.IOException e) { + Assert.fail("Failed to read key file for metadata validation in " + testDescription + ": " + e.getMessage()); + } + } + + @Test + public void tesForwardCompatibility_Phase1to2() throws ClientException { + // Test that a key created with phase 1 provider can be read by phase 2 provider + enablePhase1Flights(); + final KeyStoreBackedSecretKeyProvider phase1KeyProvider = createKeyProvider(); + + // Step 1: Create key with phase 1 provider + final SecretKey phase1Key = phase1KeyProvider.generateNewSecretKey(); + // Validate legacy format file properties + validateFileIsLegacyFormat(); + + // Step 2: Try to read the same key with phase 2 + enablePhase2Flights(); + final KeyStoreBackedSecretKeyProvider phase2Provider = createKeyProvider(); + + final SecretKey phase2Key = phase2Provider.readSecretKeyFromStorage(); + + // Validate key equivalence + validateKeyEquivalence(phase1Key, phase2Key, "phase 1 to phase 2 compatibility"); + } + + @Test + public void testBackwardCompatibility_Phase2to1() throws ClientException { + // Test that a key created with phase 2 provider can be read by phase 1 provider + enablePhase2Flights(); + final KeyStoreBackedSecretKeyProvider phase2KeyProvider = createKeyProvider(); + + // Step 1: Create key with phase 2 provider + final SecretKey phase2Key = phase2KeyProvider.generateNewSecretKey(); + // Validate new format file properties + validateFileINewFormat(); + + // Step 2: Try to read the same key with phase 1 + enablePhase1Flights(); + final KeyStoreBackedSecretKeyProvider phase1Provider = createKeyProvider(); + final SecretKey phase1Key = phase1Provider.readSecretKeyFromStorage(); + + // Validate key equivalence + validateKeyEquivalence(phase2Key, phase1Key, "phase 2 to phase 1 backward compatibility"); + } + + @Test + public void testForwardCompatibility_Phase2to3() throws ClientException { + // Test that a key created with phase 2 provider can be read by phase 3 provider + enablePhase2Flights(); + final KeyStoreBackedSecretKeyProvider phase2KeyProvider = createKeyProvider(); + + // Step 1: Create key with phase 2 provider + final SecretKey phase2Key = phase2KeyProvider.generateNewSecretKey(); + + // Validate Phase 2 format file properties and algorithm metadata + validateFileINewFormat(); + validateKeyMetadata("PKCS1", "OAEP", "Phase 2 key"); + + // Step 2: Try to read the same key with phase 3 + enablePhase3Flights(); + final KeyStoreBackedSecretKeyProvider phase3Provider = createKeyProvider(); + + final SecretKey phase3Key = phase3Provider.readSecretKeyFromStorage(); + + // Validate key equivalence + validateKeyEquivalence(phase2Key, phase3Key,"phase 2 to phase 3 forward compatibility"); + } + + @Test + public void testBackwardCompatibility_Phase3to2() throws ClientException { + // Test that a key created with phase 3 provider can be read by phase 2 provider + enablePhase3Flights(); + final KeyStoreBackedSecretKeyProvider phase3KeyProvider = createKeyProvider(); + + // Step 1: Create key with phase 3 provider + final SecretKey phase3Key = phase3KeyProvider.generateNewSecretKey(); + Assert.assertNotNull("Provider key should not be null", phase3Key); + + // Validate Phase 3 format file properties + validateFileINewFormat(); + validateKeyMetadata("OAEP", "PKCS1", "New Phase 3 key"); + + + // Step 2: Try to read the same key with phase 2 + enablePhase2Flights(); + final KeyStoreBackedSecretKeyProvider phase2Provider = createKeyProvider(); + final SecretKey phase2Key = phase2Provider.readSecretKeyFromStorage(); + + // Validate key equivalence + validateKeyEquivalence(phase3Key, phase2Key, "phase 3 to phase 2 backward compatibility"); + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index 870221110e..49ff4156a1 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -51,14 +51,10 @@ import javax.crypto.SecretKey * @param filePath The file path where the wrapped secret key is stored. * @param context The context used to access the Android KeyStore and file system. */ -class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( +class KeyStoreBackedSecretKeyProvider( context: Context, override val alias: String, - private val filePath: String, - private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory( - context, - alias - ) + private val filePath: String ) : ISecretKeyProvider { companion object { private const val TAG = "KeyStoreBackedSecretKeyProvider" @@ -87,6 +83,11 @@ class KeyStoreBackedSecretKeyProvider @JvmOverloads constructor( override val keyTypeIdentifier = KEY_TYPE_IDENTIFIER override val cipherTransformation = AES_CBC_PKCS5_PADDING_TRANSFORMATION + private val cryptoParameterSpecFactory: CryptoParameterSpecFactory = CryptoParameterSpecFactory( + context, + alias + ) + /** * File where the wrapped secret key is stored. */ diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index 3799cec79b..79e8ef9dcf 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -292,4 +292,101 @@ class WrappedSecretKeyTest { assertEquals(testAlgorithm, loadedKey.algorithm) assertEquals(testCipherTransformation, loadedKey.cipherTransformation) } + + /** + * Test that verifies backward compatibility when rolling back the ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT flight. + * This test ensures that keys stored with the new format can still be read when the flight is disabled, + * which is critical for production rollback scenarios. + */ + @Test + fun flightRollbackScenario_keyStoredWithNewFormatCanBeReadWithFlightDisabled() { + val testFile = tempFolder.newFile("rollback-scenario.dat") + val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Step 1: Enable flight and store key using new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + originalKey.storeOnFile(testFile) + + // Verify the file was created with new format (contains metadata) + val fileBytes = testFile.readBytes() + assertTrue("File should be larger than raw key bytes due to metadata header", + fileBytes.size > testKeyBytes.size) + + // Verify it can be read with flight enabled + val keyWithFlightEnabled = WrappedSecretKey.loadFromFile(testFile, 1024) + assertNotNull("Key should be readable with flight enabled", keyWithFlightEnabled) + assertArrayEquals("Key data should match", originalKey.byteArray, keyWithFlightEnabled!!.byteArray) + assertEquals("Algorithm should match", originalKey.algorithm, keyWithFlightEnabled.algorithm) + assertEquals("Cipher transformation should match", originalKey.cipherTransformation, keyWithFlightEnabled.cipherTransformation) + + // Step 2: Disable flight (simulate rollback) and verify key can still be read + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + + val keyAfterRollback = WrappedSecretKey.loadFromFile(testFile, 1024) + assertNotNull("Key should still be readable after flight rollback", keyAfterRollback) + assertArrayEquals("Key data should remain intact after rollback", originalKey.byteArray, keyAfterRollback!!.byteArray) + assertEquals("Algorithm should be preserved from metadata", originalKey.algorithm, keyAfterRollback.algorithm) + assertEquals("Cipher transformation should be preserved from metadata", originalKey.cipherTransformation, keyAfterRollback.cipherTransformation) + + // Verify that the format detection correctly identifies this as new format + // even when the flight is disabled (loadFromFile should still work) + assertEquals("Both reads should return identical key data", + keyWithFlightEnabled.byteArray.contentToString(), + keyAfterRollback.byteArray.contentToString()) + assertEquals("Both reads should return identical algorithm", + keyWithFlightEnabled.algorithm, + keyAfterRollback.algorithm) + assertEquals("Both reads should return identical cipher transformation", + keyWithFlightEnabled.cipherTransformation, + keyAfterRollback.cipherTransformation) + } + + /** + * Test that verifies forward compatibility when enabling the ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT flight. + * This test ensures that keys stored with the old format can still be read when the flight is enabled, + * which is important when rolling out the new feature. + */ + @Test + fun flightRolloutScenario_keyStoredWithOldFormatCanBeReadWithFlightEnabled() { + val testFile = tempFolder.newFile("rollout-scenario.dat") + val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Step 1: Disable flight and store key using old format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + + originalKey.storeOnFile(testFile) + + // Verify the file was created with old format (only raw key bytes) + val fileBytes = testFile.readBytes() + assertArrayEquals("Old format should contain only raw key bytes", testKeyBytes, fileBytes) + + // Verify it can be read with flight disabled (uses default algorithm and cipher) + val keyWithFlightDisabled = WrappedSecretKey.loadFromFile(testFile, 1024) + assertNotNull("Key should be readable with flight disabled", keyWithFlightDisabled) + assertArrayEquals("Key data should match", originalKey.byteArray, keyWithFlightDisabled!!.byteArray) + assertEquals("Should use default algorithm", "AES", keyWithFlightDisabled.algorithm) + assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", keyWithFlightDisabled.cipherTransformation) + + // Step 2: Enable flight (simulate rollout) and verify key can still be read + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + val keyAfterRollout = WrappedSecretKey.loadFromFile(testFile, 1024) + assertNotNull("Key should still be readable after flight rollout", keyAfterRollout) + assertArrayEquals("Key data should remain intact after rollout", originalKey.byteArray, keyAfterRollout!!.byteArray) + assertEquals("Should still use default algorithm for old format", "AES", keyAfterRollout.algorithm) + assertEquals("Should still use default cipher transformation for old format", "RSA/ECB/PKCS1Padding", keyAfterRollout.cipherTransformation) + + // Verify that both reads return the same data (backward compatibility maintained) + assertEquals("Both reads should return identical key data", + keyWithFlightDisabled.byteArray.contentToString(), + keyAfterRollout.byteArray.contentToString()) + assertEquals("Both reads should return identical algorithm", + keyWithFlightDisabled.algorithm, + keyAfterRollout.algorithm) + assertEquals("Both reads should return identical cipher transformation", + keyWithFlightDisabled.cipherTransformation, + keyAfterRollout.cipherTransformation) + } } + diff --git a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java index e22c7781a1..c33e292848 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java +++ b/common4j/src/main/com/microsoft/identity/common/java/util/FileUtil.java @@ -27,10 +27,11 @@ import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.nio.file.Files; import edu.umd.cs.findbugs.annotations.Nullable; import lombok.NonNull; @@ -52,13 +53,17 @@ private FileUtil() { * @param data a data blob to be written. * @param file file to write to. */ - public static void writeDataToFile(final byte @NonNull [] data, + public static void writeDataToFile(@NonNull final byte[] data, @NonNull final File file) throws ClientException { final String methodName = ":writeKeyData"; - - try (OutputStream out = Files.newOutputStream(file.toPath())) { - out.write(data); + try { + final OutputStream out = new FileOutputStream(file); + try { + out.write(data); + } finally { + out.close(); + } } catch (IOException e) { final ClientException clientException = new ClientException( IO_ERROR, @@ -92,16 +97,20 @@ public static byte[] readFromFile(@NonNull final File file, return null; } - - - try (InputStream in = Files.newInputStream(file.toPath())) { - final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - final byte[] buffer = new byte[dataSize]; - int count; - while ((count = in.read(buffer)) != -1) { - bytes.write(buffer, 0, count); + try { + final InputStream in = new FileInputStream(file); + + try { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final byte[] buffer = new byte[dataSize]; + int count; + while ((count = in.read(buffer)) != -1) { + bytes.write(buffer, 0, count); + } + return bytes.toByteArray(); + } finally { + in.close(); } - return bytes.toByteArray(); } catch (IOException e) { final ClientException clientException = new ClientException( IO_ERROR, From 4818c5bc4afd9d883adce1c4dc0afaef2066af98 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 20 Aug 2025 11:00:55 -0700 Subject: [PATCH 35/51] feat: Implement new wrapped secret key format with metadata and compatibility tests --- .../common/crypto/WrappedSecretKey.kt | 129 +++++---- .../common/crypto/WrappedSecretKeyTest.kt | 257 +++++++++++++++--- 2 files changed, 298 insertions(+), 88 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt index 3eb77c3b66..2049d219b3 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt @@ -29,8 +29,15 @@ import com.microsoft.identity.common.java.util.FileUtil import com.microsoft.identity.common.logging.Logger import org.json.JSONObject import java.io.File +import java.io.IOException import java.nio.ByteBuffer +/** + * Represents a wrapped secret key with metadata for algorithm and cipher transformation. + * + * This class supports both old and new storage formats for wrapped keys. + * The new format includes metadata header for better compatibility and extensibility. + */ class WrappedSecretKey( val byteArray: ByteArray, val algorithm: String, @@ -51,28 +58,9 @@ class WrappedSecretKey( .isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) if (useNewFormat) { - // New format: Store metadata header + raw key data - val metadata = JSONObject().apply { - put("algorithm", algorithm) - put("cipherTransformation", cipherTransformation) - put("version", NEW_FORMAT_VERSION) - put("keyDataLength", byteArray.size) - } - val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) - - // Use ByteBuffer for cleaner header writing - val output = ByteBuffer.allocate(4 + metadataBytes.size + byteArray.size) - .putInt(metadataBytes.size) // Write header length (4 bytes, big-endian) - .put(metadataBytes) // Write metadata - .put(byteArray) // Write raw key data - .array() - - FileUtil.writeDataToFile(output, file) - Logger.info(methodTag, "Key successfully stored on disk using optimized new format.") + storeOnFileNewFormat(file) } else { - // Old format: Store only raw key bytes FileUtil.writeDataToFile(byteArray, file) - Logger.info(methodTag, "Key successfully stored on disk using old format.") } } catch (e: Exception) { Logger.error(methodTag, "Failed to store key on disk", e) @@ -80,11 +68,50 @@ class WrappedSecretKey( } } + /** + * Stores the wrapped secret key in the new binary format. + * + * The new format structure is: + * - Header identifier (4 bytes) + * - Metadata length (4 bytes) + * - Protobuf-serialized metadata + * - Raw key data + * + * @param file The file to store the key data + */ + private fun storeOnFileNewFormat(file: File) { + val methodTag = "$TAG:storeOnFileNewFormat" + + // New format: Store metadata header + raw key data + val metadata = JSONObject().apply { + put("algorithm", algorithm) + put("cipherTransformation", cipherTransformation) + put("version", FORMAT_VERSION_1) + put("keyDataLength", byteArray.size) + } + + val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) + + // Use ByteBuffer for cleaner header writing + val output = ByteBuffer.allocate(Int.SIZE_BYTES + Int.SIZE_BYTES + metadataBytes.size + byteArray.size) + .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Write header length (4 bytes, big-endian) + .putInt(metadataBytes.size) // Write metadata length (4 bytes, big-endian) + .put(metadataBytes) // Write metadata + .put(byteArray) // Write raw key data + .array() + + FileUtil.writeDataToFile(output, file) + Logger.info(methodTag, "Key successfully stored on disk using optimized new format.") + } + + companion object { + private const val TAG = "WrappedSecretKey" - private const val NEW_FORMAT_VERSION = "1.0" + private const val FORMAT_VERSION_1 = 1 private const val DEFAULT_ALGORITHM = "AES" private const val DEFAULT_CIPHER_TRANSFORMATION = "RSA/ECB/PKCS1Padding" + private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF12AB /** * Loads a wrapped secret key from file, automatically detecting the storage format. @@ -115,7 +142,7 @@ class WrappedSecretKey( } } catch (e: Exception) { Logger.error(methodTag, "Failed to load key from file", e) - throw ClientException(ClientException.IO_ERROR, "Failed to load key from file", e) + throw ClientException(ClientException.KEY_LOAD_FAILURE, "Failed to load key from file", e) } } @@ -126,57 +153,55 @@ class WrappedSecretKey( * @return true if data is in new format, false if old format */ private fun isNewFormat(rawData: ByteArray): Boolean { - return try { - // New format starts with 4-byte header length, followed by JSON metadata - if (rawData.size < 4) return false - - val buffer = ByteBuffer.wrap(rawData) - val headerLength = buffer.getInt() - - // Sanity check: header length should be reasonable - if (headerLength < 10 || headerLength > rawData.size - 4) return false - - // Try to parse the metadata JSON - val metadataBytes = ByteArray(headerLength) - buffer.get(metadataBytes) - val jsonString = String(metadataBytes, Charsets.UTF_8) - val json = JSONObject(jsonString) - - // Check if it has the expected new format fields - json.has("algorithm") && json.has("cipherTransformation") && json.has("version") - } catch (e: Exception) { - // If parsing fails, assume it's old format - false - } + if (rawData.size < 8) return false + val buffer = ByteBuffer.wrap(rawData) + return buffer.getInt() == NEW_FORMAT_HEADER_IDENTIFIER } /** * Loads wrapped secret key from new binary format. * + * The new format structure is: + * - Header identifier (4 bytes) + * - Metadata length (4 bytes) + * - Protobuf-serialized metadata + * - Raw key data + * * @param rawData The raw binary data * @return WrappedSecretKey instance + * @throws ClientException if the data format is invalid or parsing fails */ + @Throws(IOException::class) private fun loadFromNewFormat(rawData: ByteArray): WrappedSecretKey { val methodTag = "$TAG:loadFromNewFormat" Logger.info(methodTag, "Loading key using optimized new binary format") - val buffer = ByteBuffer.wrap(rawData) - val headerLength = buffer.getInt() - // Extract and parse metadata - val metadataBytes = ByteArray(headerLength) + // Skip header identifier (already validated in isNewFormat) + buffer.getInt() + + // Read metadata length + val metadataLength = buffer.getInt() + + // Extract and parse protobuf metadata + val metadataBytes = ByteArray(metadataLength) buffer.get(metadataBytes) + val jsonString = String(metadataBytes, Charsets.UTF_8) val json = JSONObject(jsonString) - val algorithm = json.getString("algorithm") val cipherTransformation = json.getString("cipherTransformation") - val keyDataLength = json.optInt("keyDataLength", buffer.remaining()) + val keyDataLength = json.getInt("keyDataLength") + + // Validate key data length + if (keyDataLength != buffer.remaining()) { + Logger.warn(methodTag, "Key data length mismatch. Expected: $keyDataLength, Actual: ${buffer.remaining()}") + } - // Extract raw key data - val keyBytes = ByteArray(keyDataLength) + val keyBytes = ByteArray(buffer.remaining()) buffer.get(keyBytes) + Logger.verbose(methodTag, "Successfully loaded key with algorithm: $algorithm, transformation: $cipherTransformation") return WrappedSecretKey(keyBytes, algorithm, cipherTransformation) } diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index 79e8ef9dcf..eee4ab3104 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -22,6 +22,7 @@ // THE SOFTWARE. package com.microsoft.identity.common.crypto +import com.microsoft.identity.common.java.exception.ClientException import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager import io.mockk.every @@ -46,6 +47,12 @@ class WrappedSecretKeyTest { private val testAlgorithm = "AES" private val testCipherTransformation = "RSA/ECB/PKCS1Padding" + companion object{ + // New format constants matching the implementation + private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF12AB + private const val FORMAT_VERSION_1 = 1 + + } @Before fun setUp() { mockkObject(CommonFlightsManager) @@ -157,18 +164,25 @@ class WrappedSecretKeyTest { val fileBytes = testFile.readBytes() val buffer = ByteBuffer.wrap(fileBytes) - val headerLength = buffer.getInt() - assertTrue("Header length should be reasonable", headerLength > 0 && headerLength < fileBytes.size) + // Verify header identifier (4 bytes) + val headerIdentifier = buffer.getInt() + assertEquals("Header identifier should match expected value", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) - val metadataBytes = ByteArray(headerLength) + // Verify metadata length (4 bytes) + val metadataLength = buffer.getInt() + assertTrue("Metadata length should be reasonable", metadataLength > 0 && metadataLength < fileBytes.size) + + // Read and verify metadata + val metadataBytes = ByteArray(metadataLength) buffer.get(metadataBytes) val metadata = JSONObject(String(metadataBytes, Charsets.UTF_8)) assertEquals(testAlgorithm, metadata.getString("algorithm")) assertEquals(testCipherTransformation, metadata.getString("cipherTransformation")) - assertEquals("1.0", metadata.getString("version")) + assertEquals(FORMAT_VERSION_1, metadata.getInt("version")) assertEquals(testKeyBytes.size, metadata.getInt("keyDataLength")) + // Verify remaining data is the key val remainingKeyData = ByteArray(buffer.remaining()) buffer.get(remainingKeyData) assertArrayEquals(testKeyBytes, remainingKeyData) @@ -209,41 +223,14 @@ class WrappedSecretKeyTest { assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) } - @Test - fun newFormatWithMissingKeyDataLengthUsesFallback() { - val testFile = tempFolder.newFile("missing-length.dat") - - // Create metadata without keyDataLength - val metadata = JSONObject().apply { - put("algorithm", testAlgorithm) - put("cipherTransformation", testCipherTransformation) - put("version", "1.0") - // Intentionally omit keyDataLength - } - val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) - - // Write binary format manually - val buffer = ByteBuffer.allocate(4 + metadataBytes.size + testKeyBytes.size) - .putInt(metadataBytes.size) - .put(metadataBytes) - .put(testKeyBytes) - testFile.writeBytes(buffer.array()) - - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) - - assertNotNull(loadedKey) - assertArrayEquals(testKeyBytes, loadedKey!!.byteArray) - assertEquals(testAlgorithm, loadedKey.algorithm) - assertEquals(testCipherTransformation, loadedKey.cipherTransformation) - } - @Test fun corruptedNewFormatFallsBackToOldFormat() { val testFile = tempFolder.newFile("corrupted.dat") - // Create invalid new format data + // Create invalid new format data (wrong header identifier) val invalidData = ByteBuffer.allocate(20) - .putInt(100) // Header length larger than available data + .putInt(0x12345678) // Wrong header identifier + .putInt(100) // Metadata length larger than available data .put("invalid".toByteArray()) .array() testFile.writeBytes(invalidData) @@ -251,7 +238,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull(loadedKey) - assertArrayEquals(invalidData, loadedKey!!.byteArray) // Should load as raw bytes + assertArrayEquals(invalidData, loadedKey!!.byteArray) // Should load as raw bytes (old format) assertEquals("AES", loadedKey.algorithm) assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) } @@ -388,5 +375,203 @@ class WrappedSecretKeyTest { keyWithFlightDisabled.cipherTransformation, keyAfterRollout.cipherTransformation) } -} + /** + * Test that validates the wrapped key is using the new format when the flight is enabled. + * This test verifies that the stored key contains the proper binary structure and metadata. + */ + @Test + fun validateNewFormatKeyStructure() { + val testFile = tempFolder.newFile("new-format-validation.dat") + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Enable new format flight + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + wrappedKey.storeOnFile(testFile) + + // Validate the file structure manually + val fileBytes = testFile.readBytes() + val buffer = ByteBuffer.wrap(fileBytes) + + // Check header identifier + val headerIdentifier = buffer.getInt() + assertEquals("New format should have correct header identifier", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) + + // Check metadata + val metadataLength = buffer.getInt() + val metadataBytes = ByteArray(metadataLength) + buffer.get(metadataBytes) + val metadata = JSONObject(String(metadataBytes, Charsets.UTF_8)) + + assertTrue("Metadata should contain algorithm", metadata.has("algorithm")) + assertTrue("Metadata should contain cipherTransformation", metadata.has("cipherTransformation")) + assertTrue("Metadata should contain version", metadata.has("version")) + assertTrue("Metadata should contain keyDataLength", metadata.has("keyDataLength")) + + assertEquals("Algorithm should match", testAlgorithm, metadata.getString("algorithm")) + assertEquals("Cipher transformation should match", testCipherTransformation, metadata.getString("cipherTransformation")) + assertEquals("Version should be 1", FORMAT_VERSION_1, metadata.getInt("version")) + assertEquals("Key data length should match", testKeyBytes.size, metadata.getInt("keyDataLength")) + } + + /** + * Test that validates the wrapped key is using the legacy (old) format when the flight is disabled. + * This test verifies that the stored key contains only raw key bytes without metadata. + */ + @Test + fun validateLegacyFormatKeyStructure() { + val testFile = tempFolder.newFile("legacy-format-validation.dat") + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Disable new format flight + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + + wrappedKey.storeOnFile(testFile) + + // Validate the file contains only raw key bytes + val fileBytes = testFile.readBytes() + assertArrayEquals("Legacy format should contain only raw key bytes", testKeyBytes, fileBytes) + assertEquals("Legacy format file size should equal key size", testKeyBytes.size.toLong(), testFile.length()) + } + + /** + * Test that creates a key with new format enabled, then disables the flight and reads the key. + * This simulates a rollback scenario where the key should still be readable. + */ + @Test + fun testBackwardCompatibility_NewFormatToLegacy() { + val testFile = tempFolder.newFile("backward-compatibility.dat") + val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Phase 1: Enable flight and create key with new format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + originalKey.storeOnFile(testFile) + + // Validate key is created with new format + val fileBytes = testFile.readBytes() + val buffer = ByteBuffer.wrap(fileBytes) + val headerIdentifier = buffer.getInt() + assertEquals("Key should be created with new format", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) + + // Phase 2: Disable flight and read the key (rollback scenario) + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + assertNotNull("Key should still be readable after flight rollback", loadedKey) + assertArrayEquals("Key data should be preserved", originalKey.byteArray, loadedKey!!.byteArray) + assertEquals("Algorithm should be preserved from metadata", originalKey.algorithm, loadedKey.algorithm) + assertEquals("Cipher transformation should be preserved from metadata", originalKey.cipherTransformation, loadedKey.cipherTransformation) + } + + /** + * Test that creates a key with legacy format, then enables the flight and reads the key. + * This simulates a rollout scenario where existing legacy keys should still be readable. + */ + @Test + fun testForwardCompatibility_LegacyToNewFormat() { + val testFile = tempFolder.newFile("forward-compatibility.dat") + val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + + // Phase 1: Disable flight and create key with legacy format + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + originalKey.storeOnFile(testFile) + + // Validate key is created with legacy format + val fileBytes = testFile.readBytes() + assertArrayEquals("Key should be created with legacy format", testKeyBytes, fileBytes) + + // Phase 2: Enable flight and read the key (rollout scenario) + every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + assertNotNull("Legacy key should still be readable after flight rollout", loadedKey) + assertArrayEquals("Key data should be preserved", originalKey.byteArray, loadedKey!!.byteArray) + assertEquals("Should use default algorithm for legacy format", "AES", loadedKey.algorithm) + assertEquals("Should use default cipher transformation for legacy format", "RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + } + + /** + * Test edge case where metadata length is corrupted but header identifier is correct. + * Since the header identifier matches new format, it should throw an exception rather than fall back. + */ + @Test + fun corruptedMetadataLengthThrowsException() { + val testFile = tempFolder.newFile("corrupted-metadata-length.dat") + + // Create new format with corrupted metadata length + val corruptedData = ByteBuffer.allocate(16) + .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Correct header identifier + .putInt(-1) // Invalid metadata length (negative) + .put("test".toByteArray()) + .array() + testFile.writeBytes(corruptedData) + + // Should throw ClientException because it detects new format but has invalid metadata length + try { + WrappedSecretKey.loadFromFile(testFile, 1024) + fail("Should have thrown ClientException for corrupted new format") + } catch (e: ClientException) { + assertEquals("Should throw KEY_LOAD_FAILURE error", + ClientException.KEY_LOAD_FAILURE, + e.errorCode) + + } + } + + /** + * Test edge case where metadata length is larger than remaining buffer size. + */ + @Test + fun metadataLengthLargerThanRemainingDataThrowsException() { + val testFile = tempFolder.newFile("metadata-too-large.dat") + + // Create new format with metadata length larger than remaining data + val corruptedData = ByteBuffer.allocate(16) + .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Correct header identifier + .putInt(1000) // Metadata length larger than remaining data + .put("test".toByteArray()) + .array() + testFile.writeBytes(corruptedData) + + // Should throw ClientException because metadata length exceeds remaining buffer + try { + WrappedSecretKey.loadFromFile(testFile, 1024) + fail("Should have thrown ClientException for metadata length exceeding buffer size") + } catch (e: ClientException) { + assertEquals("Should throw KEY_LOAD_FAILURE error", + ClientException.KEY_LOAD_FAILURE, + e.errorCode) + } + } + + /** + * Test that verifies empty file handling. + */ + @Test + fun emptyFileReturnsNull() { + val testFile = tempFolder.newFile("empty.dat") + // File exists but is empty + + val result = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNull("Empty file should return null", result) + } + + /** + * Test that verifies very small file handling (less than header size). + */ + @Test + fun smallFileIsDetectedAsOldFormat() { + val testFile = tempFolder.newFile("small.dat") + val smallData = "test".toByteArray() // Less than 8 bytes needed for header + testFile.writeBytes(smallData) + + val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + + assertNotNull("Small file should be treated as old format", loadedKey) + assertArrayEquals("Should load as raw bytes", smallData, loadedKey!!.byteArray) + assertEquals("Should use default algorithm", "AES", loadedKey.algorithm) + assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + } +} From 5845348eb17a281a916d684dbdb7656ba57608d6 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 21 Aug 2025 14:33:18 -0700 Subject: [PATCH 36/51] feat: Introduce static flag to skip key invalidation checks for performance optimization --- .../common/crypto/AndroidWrappedKeyProvider.java | 9 ++++++++- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 3 ++- .../KeyStoreBackedSecretKeyProviderFactory.kt | 13 ------------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java index 8944b9f6ba..00d87c3c00 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java +++ b/common/src/main/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProvider.java @@ -64,6 +64,7 @@ import javax.security.auth.x500.X500Principal; import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.context.Scope; @@ -86,6 +87,12 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { private static final String TAG = AndroidWrappedKeyProvider.class.getSimpleName() + "#"; + /** + * Should KeyStore and key file check for validity before every key load be skipped. + */ + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + public static boolean sSkipKeyInvalidationCheck = false; + /** * Algorithm for key wrapping. */ @@ -126,7 +133,7 @@ public class AndroidWrappedKeyProvider implements ISecretKeyProvider { @VisibleForTesting /* package */ SecretKey getKeyFromCache() { final String methodTag = TAG + ":getKeyFromCache"; - if (!KeyStoreBackedSecretKeyProviderFactory.skipKeyInvalidationCheck && + if (!sSkipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(mAlias) || !this.getKeyFile().exists())) { Logger.warn(methodTag, "Key is invalid, removing from cache"); clearKeyFromCache(); diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index 49ff4156a1..e5a21a707c 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -118,7 +118,8 @@ class KeyStoreBackedSecretKeyProvider( } private fun clearCachedKeyIfCantLoadOrFileDoesNotExist() { - val shouldClearCache = !KeyStoreBackedSecretKeyProviderFactory.skipKeyInvalidationCheck && + // TODO: Replace on next OneAuth major release. + val shouldClearCache = !AndroidWrappedKeyProvider.sSkipKeyInvalidationCheck && (!AndroidKeyStoreUtil.canLoadKey(alias) || !keyFile.exists()) if (shouldClearCache) { sKeyCacheMap.remove(filePath) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt index a9bb72f517..8724207294 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderFactory.kt @@ -36,19 +36,6 @@ import com.microsoft.identity.common.java.flighting.CommonFlightsManager * */ object KeyStoreBackedSecretKeyProviderFactory { - - /** - * Controls whether key invalidation checks should be skipped during key loading operations. - * - * When set to `true`, the key providers will skip validation checks for KeyStore and key file - * integrity before every key load operation. This can improve performance but may reduce - * security guarantees. - * - * Default: `false` (key invalidation checks are performed) - */ - @JvmField - var skipKeyInvalidationCheck: Boolean = false - /** * Creates an appropriate [ISecretKeyProvider] instance based on current feature flag settings. * From 8f275c90354a20a6108af0764ad1ace7d87a2cf6 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Mon, 25 Aug 2025 10:31:07 -0700 Subject: [PATCH 37/51] refactor: Update AndroidWrappedKeyProviderTest to use provider type keywords instead of classes --- .../crypto/AndroidWrappedKeyProviderTest.java | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java index 815582aaf8..f02d855f13 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/AndroidWrappedKeyProviderTest.java @@ -43,7 +43,6 @@ import org.junit.runners.Parameterized; import java.io.File; -import java.lang.reflect.Constructor; import java.math.BigInteger; import java.security.KeyPair; import java.security.spec.AlgorithmParameterSpec; @@ -57,29 +56,25 @@ @RunWith(Parameterized.class) public class AndroidWrappedKeyProviderTest { - @Parameterized.Parameter(0) - public String providerName; - - @Parameterized.Parameter(1) - public Class providerClass; + public String providerType; @Parameterized.Parameters(name = "{0}") public static Collection data() { return Arrays.asList(new Object[][] { - {"KeyStoreBackedSecretKeyProvider", KeyStoreBackedSecretKeyProvider.class}, - {"AndroidWrappedKeyProvider", AndroidWrappedKeyProvider.class} - // Add other implementations here + {"KEYSTORE_BACKED"}, + {"ANDROID_WRAPPED"} + // Add other provider types here as keywords }); } private ISecretKeyProvider createProvider() { - try { - Constructor constructor = - providerClass.getDeclaredConstructor(String.class, String.class, Context.class); - return constructor.newInstance(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); - } catch (Exception e) { - throw new RuntimeException("Failed to create provider instance", e); + if ("KEYSTORE_BACKED".equals(providerType)) { + return new KeyStoreBackedSecretKeyProvider(context, MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH); + } else if ("ANDROID_WRAPPED".equals(providerType)) { + return new AndroidWrappedKeyProvider(MOCK_KEY_ALIAS, MOCK_KEY_FILE_PATH, context); + } else { + throw new IllegalArgumentException("Unsupported provider type: " + providerType); } } From a2485d525a53fe11aee09e528c2014f11cdff253 Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:03:33 -0700 Subject: [PATCH 38/51] Update common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt Co-authored-by: Sowmya Malayanur <69237821+somalaya@users.noreply.github.com> --- .../com/microsoft/identity/common/crypto/WrappedSecretKey.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt index 2049d219b3..f2bb7af370 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt @@ -63,7 +63,9 @@ class WrappedSecretKey( FileUtil.writeDataToFile(byteArray, file) } } catch (e: Exception) { - Logger.error(methodTag, "Failed to store key on disk", e) + val errorMessage = "Failed to store key on disk" + Logger.error(methodTag, errorMessage , e) + throw ClientException(ClientException.IO_ERROR, errorMessage , e) throw ClientException(ClientException.IO_ERROR, "Failed to store key on disk", e) } } From 80edd0ef6367dcadfd93174d295acebaa402133b Mon Sep 17 00:00:00 2001 From: pedro romero vargas <76129899+p3dr0rv@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:03:57 -0700 Subject: [PATCH 39/51] Update common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt Co-authored-by: Sowmya Malayanur <69237821+somalaya@users.noreply.github.com> --- .../com/microsoft/identity/common/crypto/WrappedSecretKey.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt index f2bb7af370..f3bd6b9c9c 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt @@ -149,7 +149,7 @@ class WrappedSecretKey( } /** - * Determines if the raw data is in new binary format or old binary format. + * Determines if the raw data uses the new wrapped key format. * * @param rawData The raw bytes read from file * @return true if data is in new format, false if old format From 6d0e060718f963984203f3644dbbf5b11263d9d1 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Wed, 3 Sep 2025 12:55:22 -0700 Subject: [PATCH 40/51] refactor: Rename byteArray to wrappedKeyData in WrappedSecretKey and update related tests --- .../crypto/CryptoParameterSpecFactory.kt | 14 ++++---- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 4 +-- .../common/crypto/WrappedSecretKey.kt | 16 ++++----- .../common/crypto/WrappedSecretKeyTest.kt | 36 +++++++++---------- 4 files changed, 34 insertions(+), 36 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt index 8fb0841c42..187de30c22 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactory.kt @@ -80,9 +80,9 @@ class CryptoParameterSpecFactory( } // Feature flags to control which key generation specs to use. (evaluated every time accessed) - private val keySpecWithPurposeKey get() = + private val keySpecWithWrapPurposeKey get() = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY) - private val keySpecWithoutPurposeKey get() = + private val keySpecWithoutWrapPurposeKey get() = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY) private val enableKeyGenEncryptionPaddingRsaOaep get() = flightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING) @@ -93,8 +93,8 @@ class CryptoParameterSpecFactory( methodTag, "Initialized CryptoParameterSpecFactory - " + "API: ${Build.VERSION.SDK_INT}, " + - "flags: [purposeKey=$keySpecWithPurposeKey, " + - "withoutPurposeKey=$keySpecWithoutPurposeKey, " + + "flags: [keySpecWithWrapPurposeKey=$keySpecWithWrapPurposeKey, " + + "keySpecWithoutWrapPurposeKey=$keySpecWithoutWrapPurposeKey, " + "oaepSupported=$enableKeyGenEncryptionPaddingRsaOaep]" ) } @@ -118,7 +118,6 @@ class CryptoParameterSpecFactory( ) } - @delegate:RequiresApi(Build.VERSION_CODES.M) private val keyGenParamSpecWithoutPurposeWrapKey by lazy { KeyGenSpec( keyAlias = keyAlias, @@ -144,7 +143,6 @@ class CryptoParameterSpecFactory( algorithm = RSA_ALGORITHM ) - @RequiresApi(Build.VERSION_CODES.M) private fun getEncryptionPaddingsForKeyGen(): List { val paddings = mutableListOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) if (enableKeyGenEncryptionPaddingRsaOaep) { @@ -184,12 +182,12 @@ class CryptoParameterSpecFactory( val specs = mutableListOf() // Add specs in order of preference - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && keySpecWithPurposeKey) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && keySpecWithWrapPurposeKey) { // First priority: API 28+ with PURPOSE_WRAP_KEY if enabled specs.add(keyGenParamSpecWithPurposeWrapKey) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && keySpecWithoutPurposeKey) { + if (keySpecWithoutWrapPurposeKey) { // Second priority: API 23+ without PURPOSE_WRAP_KEY specs.add(keyGenParamSpecWithoutPurposeWrapKey) } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index e5a21a707c..04d0bf64f8 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -201,7 +201,7 @@ class KeyStoreBackedSecretKeyProvider( } val (keyWrapped, cipher) = wrapSecretKey(newSecretKey, keyPair) WrappedSecretKey( - byteArray = keyWrapped, + wrappedKeyData = keyWrapped, algorithm = AES256SecretKeyGenerator.AES_ALGORITHM, cipherTransformation = cipher.transformation ).storeOnFile(keyFile) @@ -315,7 +315,7 @@ class KeyStoreBackedSecretKeyProvider( ) Logger.info(methodTag, "Unwrapping secret key with cipher spec: $cipherParamsSpec") val key = AndroidKeyStoreUtil.unwrap( - wrappedSecretKey.byteArray, + wrappedSecretKey.wrappedKeyData, wrappedSecretKey.algorithm, keyPair, cipherParamsSpec.transformation, diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt index 2049d219b3..6c82845e9f 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt @@ -39,7 +39,7 @@ import java.nio.ByteBuffer * The new format includes metadata header for better compatibility and extensibility. */ class WrappedSecretKey( - val byteArray: ByteArray, + val wrappedKeyData: ByteArray, val algorithm: String, val cipherTransformation: String ) { @@ -52,15 +52,15 @@ class WrappedSecretKey( fun storeOnFile(file: File) { val methodTag = "WrappedSecretKey:storeOnFile" try { - val useNewFormat = + val useNewSecretKeyFormat = CommonFlightsManager .getFlightsProvider() .isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) - if (useNewFormat) { + if (useNewSecretKeyFormat) { storeOnFileNewFormat(file) } else { - FileUtil.writeDataToFile(byteArray, file) + FileUtil.writeDataToFile(wrappedKeyData, file) } } catch (e: Exception) { Logger.error(methodTag, "Failed to store key on disk", e) @@ -87,17 +87,17 @@ class WrappedSecretKey( put("algorithm", algorithm) put("cipherTransformation", cipherTransformation) put("version", FORMAT_VERSION_1) - put("keyDataLength", byteArray.size) + put("keyDataLength", wrappedKeyData.size) } val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) // Use ByteBuffer for cleaner header writing - val output = ByteBuffer.allocate(Int.SIZE_BYTES + Int.SIZE_BYTES + metadataBytes.size + byteArray.size) + val output = ByteBuffer.allocate(Int.SIZE_BYTES + Int.SIZE_BYTES + metadataBytes.size + wrappedKeyData.size) .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Write header length (4 bytes, big-endian) .putInt(metadataBytes.size) // Write metadata length (4 bytes, big-endian) .put(metadataBytes) // Write metadata - .put(byteArray) // Write raw key data + .put(wrappedKeyData) // Write raw key data .array() FileUtil.writeDataToFile(output, file) @@ -217,7 +217,7 @@ class WrappedSecretKey( Logger.info(methodTag, "Loading key using old format with default algorithm and cipher transformation") return WrappedSecretKey( - byteArray = rawData, + wrappedKeyData = rawData, algorithm = DEFAULT_ALGORITHM, cipherTransformation = DEFAULT_CIPHER_TRANSFORMATION ) diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index eee4ab3104..4dd7cbf9f7 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -67,7 +67,7 @@ class WrappedSecretKeyTest { fun constructorCreatesWrappedSecretKeyWithCorrectProperties() { val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - assertArrayEquals(testKeyBytes, wrappedKey.byteArray) + assertArrayEquals(testKeyBytes, wrappedKey.wrappedKeyData) assertEquals(testAlgorithm, wrappedKey.algorithm) assertEquals(testCipherTransformation, wrappedKey.cipherTransformation) } @@ -105,7 +105,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull(loadedKey) - assertArrayEquals(originalKey.byteArray, loadedKey!!.byteArray) + assertArrayEquals(originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) assertEquals(originalKey.algorithm, loadedKey.algorithm) assertEquals(originalKey.cipherTransformation, loadedKey.cipherTransformation) } @@ -125,7 +125,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull(loadedKey) - assertArrayEquals(originalKey.byteArray, loadedKey!!.byteArray) + assertArrayEquals(originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) assertEquals("AES", loadedKey.algorithm) // Default value assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) // Default value } @@ -202,7 +202,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 10240) assertNotNull(loadedKey) - assertArrayEquals(largeKeyData, loadedKey!!.byteArray) + assertArrayEquals(largeKeyData, loadedKey!!.wrappedKeyData) assertEquals(testAlgorithm, loadedKey.algorithm) assertEquals(testCipherTransformation, loadedKey.cipherTransformation) } @@ -218,7 +218,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull(loadedKey) - assertArrayEquals(binaryData, loadedKey!!.byteArray) + assertArrayEquals(binaryData, loadedKey!!.wrappedKeyData) assertEquals("AES", loadedKey.algorithm) assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) } @@ -238,7 +238,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull(loadedKey) - assertArrayEquals(invalidData, loadedKey!!.byteArray) // Should load as raw bytes (old format) + assertArrayEquals(invalidData, loadedKey!!.wrappedKeyData) // Should load as raw bytes (old format) assertEquals("AES", loadedKey.algorithm) assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) } @@ -275,7 +275,7 @@ class WrappedSecretKeyTest { // Verify it can be loaded correctly val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull(loadedKey) - assertArrayEquals(testKeyBytes, loadedKey!!.byteArray) + assertArrayEquals(testKeyBytes, loadedKey!!.wrappedKeyData) assertEquals(testAlgorithm, loadedKey.algorithm) assertEquals(testCipherTransformation, loadedKey.cipherTransformation) } @@ -303,7 +303,7 @@ class WrappedSecretKeyTest { // Verify it can be read with flight enabled val keyWithFlightEnabled = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull("Key should be readable with flight enabled", keyWithFlightEnabled) - assertArrayEquals("Key data should match", originalKey.byteArray, keyWithFlightEnabled!!.byteArray) + assertArrayEquals("Key data should match", originalKey.wrappedKeyData, keyWithFlightEnabled!!.wrappedKeyData) assertEquals("Algorithm should match", originalKey.algorithm, keyWithFlightEnabled.algorithm) assertEquals("Cipher transformation should match", originalKey.cipherTransformation, keyWithFlightEnabled.cipherTransformation) @@ -312,15 +312,15 @@ class WrappedSecretKeyTest { val keyAfterRollback = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull("Key should still be readable after flight rollback", keyAfterRollback) - assertArrayEquals("Key data should remain intact after rollback", originalKey.byteArray, keyAfterRollback!!.byteArray) + assertArrayEquals("Key data should remain intact after rollback", originalKey.wrappedKeyData, keyAfterRollback!!.wrappedKeyData) assertEquals("Algorithm should be preserved from metadata", originalKey.algorithm, keyAfterRollback.algorithm) assertEquals("Cipher transformation should be preserved from metadata", originalKey.cipherTransformation, keyAfterRollback.cipherTransformation) // Verify that the format detection correctly identifies this as new format // even when the flight is disabled (loadFromFile should still work) assertEquals("Both reads should return identical key data", - keyWithFlightEnabled.byteArray.contentToString(), - keyAfterRollback.byteArray.contentToString()) + keyWithFlightEnabled.wrappedKeyData.contentToString(), + keyAfterRollback.wrappedKeyData.contentToString()) assertEquals("Both reads should return identical algorithm", keyWithFlightEnabled.algorithm, keyAfterRollback.algorithm) @@ -351,7 +351,7 @@ class WrappedSecretKeyTest { // Verify it can be read with flight disabled (uses default algorithm and cipher) val keyWithFlightDisabled = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull("Key should be readable with flight disabled", keyWithFlightDisabled) - assertArrayEquals("Key data should match", originalKey.byteArray, keyWithFlightDisabled!!.byteArray) + assertArrayEquals("Key data should match", originalKey.wrappedKeyData, keyWithFlightDisabled!!.wrappedKeyData) assertEquals("Should use default algorithm", "AES", keyWithFlightDisabled.algorithm) assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", keyWithFlightDisabled.cipherTransformation) @@ -360,14 +360,14 @@ class WrappedSecretKeyTest { val keyAfterRollout = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull("Key should still be readable after flight rollout", keyAfterRollout) - assertArrayEquals("Key data should remain intact after rollout", originalKey.byteArray, keyAfterRollout!!.byteArray) + assertArrayEquals("Key data should remain intact after rollout", originalKey.wrappedKeyData, keyAfterRollout!!.wrappedKeyData) assertEquals("Should still use default algorithm for old format", "AES", keyAfterRollout.algorithm) assertEquals("Should still use default cipher transformation for old format", "RSA/ECB/PKCS1Padding", keyAfterRollout.cipherTransformation) // Verify that both reads return the same data (backward compatibility maintained) assertEquals("Both reads should return identical key data", - keyWithFlightDisabled.byteArray.contentToString(), - keyAfterRollout.byteArray.contentToString()) + keyWithFlightDisabled.wrappedKeyData.contentToString(), + keyAfterRollout.wrappedKeyData.contentToString()) assertEquals("Both reads should return identical algorithm", keyWithFlightDisabled.algorithm, keyAfterRollout.algorithm) @@ -459,7 +459,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull("Key should still be readable after flight rollback", loadedKey) - assertArrayEquals("Key data should be preserved", originalKey.byteArray, loadedKey!!.byteArray) + assertArrayEquals("Key data should be preserved", originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) assertEquals("Algorithm should be preserved from metadata", originalKey.algorithm, loadedKey.algorithm) assertEquals("Cipher transformation should be preserved from metadata", originalKey.cipherTransformation, loadedKey.cipherTransformation) } @@ -486,7 +486,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull("Legacy key should still be readable after flight rollout", loadedKey) - assertArrayEquals("Key data should be preserved", originalKey.byteArray, loadedKey!!.byteArray) + assertArrayEquals("Key data should be preserved", originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) assertEquals("Should use default algorithm for legacy format", "AES", loadedKey.algorithm) assertEquals("Should use default cipher transformation for legacy format", "RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) } @@ -570,7 +570,7 @@ class WrappedSecretKeyTest { val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) assertNotNull("Small file should be treated as old format", loadedKey) - assertArrayEquals("Should load as raw bytes", smallData, loadedKey!!.byteArray) + assertArrayEquals("Should load as raw bytes", smallData, loadedKey!!.wrappedKeyData) assertEquals("Should use default algorithm", "AES", loadedKey.algorithm) assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) } From 6c2d198cf0829e8c6483012d264d5927e2bded04 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 4 Sep 2025 10:57:50 -0700 Subject: [PATCH 41/51] feat: Introduce WrappedSecretKey serialization with legacy and new formats - Added WrappedSecretKeyLegacySerializer for backward compatibility with legacy key formats. - Implemented WrappedSecretKeySerializerManager to manage serialization formats and version detection. - Updated WrappedSecretKeyTest to cover serialization and deserialization for both legacy and new formats. - Enhanced version detection logic to handle different serialization formats. - Modified CommonFlight to control the WrappedSecretKey serializer version instead of a simple enable/disable flag. --- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 41 +- .../common/crypto/WrappedSecretKey.kt | 228 --------- .../IWrappedSecretKeySerializer.kt | 55 ++ .../wrappedsecretkey/WrappedSecretKey.kt | 99 ++++ .../WrappedSecretKeyJsonObjectSerializer.kt | 171 +++++++ .../WrappedSecretKeyLegacySerializer.kt | 100 ++++ .../WrappedSecretKeySerializerManager.kt | 126 +++++ .../common/crypto/WrappedSecretKeyTest.kt | 484 ++++++------------ .../common/java/flighting/CommonFlight.java | 4 +- 9 files changed, 742 insertions(+), 566 deletions(-) delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index 04d0bf64f8..f5ddf189e5 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -24,6 +24,7 @@ package com.microsoft.identity.common.crypto import android.content.Context import androidx.annotation.VisibleForTesting +import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKey import com.microsoft.identity.common.internal.util.AndroidKeyStoreUtil import com.microsoft.identity.common.java.controllers.ExceptionAdapter import com.microsoft.identity.common.java.crypto.key.AES256SecretKeyGenerator @@ -199,12 +200,10 @@ class KeyStoreBackedSecretKeyProvider( Logger.info(methodTag, "No existing keypair found. Generating a new one.") generateKeyPair() } - val (keyWrapped, cipher) = wrapSecretKey(newSecretKey, keyPair) - WrappedSecretKey( - wrappedKeyData = keyWrapped, - algorithm = AES256SecretKeyGenerator.AES_ALGORITHM, - cipherTransformation = cipher.transformation - ).storeOnFile(keyFile) + val wrappedSecretKey = wrapSecretKey(newSecretKey, keyPair) + + + FileUtil.writeDataToFile(wrappedSecretKey.serialize(), keyFile) return newSecretKey } @@ -224,8 +223,7 @@ class KeyStoreBackedSecretKeyProvider( deleteSecretKeyFromStorage() return null } - - val wrappedSecretKey = WrappedSecretKey.loadFromFile(keyFile, KEY_FILE_SIZE) + val wrappedSecretKey = loadSecretKeyFromFile() if (wrappedSecretKey == null) { Logger.warn(methodTag, "Key file is empty") // Do not delete the KeyStoreKeyPair even if the key file is empty. This caused credential cache @@ -250,7 +248,7 @@ class KeyStoreBackedSecretKeyProvider( private fun wrapSecretKey( secretKey: SecretKey, keyPair: KeyPair - ): Pair { + ): WrappedSecretKey { val methodTag = "$TAG:wrapSecretKey" val span = OTelUtility.createSpanFromParent( SpanName.SecretKeyWrapping.name, @@ -277,7 +275,11 @@ class KeyStoreBackedSecretKeyProvider( cipherParamsSpec.algorithmParameterSpec ) span.setStatus(StatusCode.OK) - wrappedKey to cipherParamsSpec + WrappedSecretKey( + wrappedKeyData = wrappedKey, + algorithm = secretKey.algorithm, + cipherTransformation = cipherTransformation + ) } } catch (exception: Exception) { Logger.error(methodTag, "Failed to wrap secret key", exception) @@ -503,4 +505,23 @@ class KeyStoreBackedSecretKeyProvider( SpanExtension.current().recordException(finalError) throw ExceptionAdapter.clientExceptionFromException(finalError) } + + /** + * Loads a wrapped secret key from file, automatically detecting the storage format. + * + * @return WrappedSecretKey instance or null if file doesn't exist or is empty + */ + private fun loadSecretKeyFromFile(): WrappedSecretKey? { + val methodTag = "$TAG:loadFromFile" + if (!keyFile.exists()) { + Logger.warn(methodTag, "Key file does not exist") + return null + } + val rawData = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) + if (rawData == null || rawData.isEmpty()) { + Logger.warn(methodTag, "Key file is empty") + return null + } + return WrappedSecretKey.deserialize(rawData) + } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt deleted file mode 100644 index fb457a6a8d..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/WrappedSecretKey.kt +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto - -import com.microsoft.identity.common.java.exception.ClientException -import com.microsoft.identity.common.java.flighting.CommonFlight -import com.microsoft.identity.common.java.flighting.CommonFlightsManager -import com.microsoft.identity.common.java.util.FileUtil -import com.microsoft.identity.common.logging.Logger -import org.json.JSONObject -import java.io.File -import java.io.IOException -import java.nio.ByteBuffer - -/** - * Represents a wrapped secret key with metadata for algorithm and cipher transformation. - * - * This class supports both old and new storage formats for wrapped keys. - * The new format includes metadata header for better compatibility and extensibility. - */ -class WrappedSecretKey( - val wrappedKeyData: ByteArray, - val algorithm: String, - val cipherTransformation: String -) { - - /** - * Stores the wrapped secret key to file. - * - * @param file The file to store the key data - */ - fun storeOnFile(file: File) { - val methodTag = "WrappedSecretKey:storeOnFile" - try { - val useNewSecretKeyFormat = - CommonFlightsManager - .getFlightsProvider() - .isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) - - if (useNewSecretKeyFormat) { - storeOnFileNewFormat(file) - } else { - FileUtil.writeDataToFile(wrappedKeyData, file) - } - } catch (e: Exception) { - val errorMessage = "Failed to store key on disk" - Logger.error(methodTag, errorMessage , e) - throw ClientException(ClientException.IO_ERROR, errorMessage , e) - throw ClientException(ClientException.IO_ERROR, "Failed to store key on disk", e) - } - } - - /** - * Stores the wrapped secret key in the new binary format. - * - * The new format structure is: - * - Header identifier (4 bytes) - * - Metadata length (4 bytes) - * - Protobuf-serialized metadata - * - Raw key data - * - * @param file The file to store the key data - */ - private fun storeOnFileNewFormat(file: File) { - val methodTag = "$TAG:storeOnFileNewFormat" - - // New format: Store metadata header + raw key data - val metadata = JSONObject().apply { - put("algorithm", algorithm) - put("cipherTransformation", cipherTransformation) - put("version", FORMAT_VERSION_1) - put("keyDataLength", wrappedKeyData.size) - } - - val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) - - // Use ByteBuffer for cleaner header writing - val output = ByteBuffer.allocate(Int.SIZE_BYTES + Int.SIZE_BYTES + metadataBytes.size + wrappedKeyData.size) - .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Write header length (4 bytes, big-endian) - .putInt(metadataBytes.size) // Write metadata length (4 bytes, big-endian) - .put(metadataBytes) // Write metadata - .put(wrappedKeyData) // Write raw key data - .array() - - FileUtil.writeDataToFile(output, file) - Logger.info(methodTag, "Key successfully stored on disk using optimized new format.") - } - - - companion object { - - private const val TAG = "WrappedSecretKey" - private const val FORMAT_VERSION_1 = 1 - private const val DEFAULT_ALGORITHM = "AES" - private const val DEFAULT_CIPHER_TRANSFORMATION = "RSA/ECB/PKCS1Padding" - private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF12AB - - /** - * Loads a wrapped secret key from file, automatically detecting the storage format. - * - * @param file The file containing the wrapped key data - * @param fileSize Maximum size to read from the file - * @return WrappedSecretKey instance or null if file doesn't exist or is empty - */ - fun loadFromFile(file: File, fileSize: Int): WrappedSecretKey? { - val methodTag = "$TAG:loadFromFile" - - if (!file.exists()) { - Logger.warn(methodTag, "Key file does not exist") - return null - } - - try { - val rawData = FileUtil.readFromFile(file, fileSize) - if (rawData == null || rawData.isEmpty()) { - Logger.warn(methodTag, "Key file is empty") - return null - } - - return if (isNewFormat(rawData)) { - loadFromNewFormat(rawData) - } else { - loadFromOldFormat(rawData) - } - } catch (e: Exception) { - Logger.error(methodTag, "Failed to load key from file", e) - throw ClientException(ClientException.KEY_LOAD_FAILURE, "Failed to load key from file", e) - } - } - - /** - * Determines if the raw data uses the new wrapped key format. - * - * @param rawData The raw bytes read from file - * @return true if data is in new format, false if old format - */ - private fun isNewFormat(rawData: ByteArray): Boolean { - if (rawData.size < 8) return false - val buffer = ByteBuffer.wrap(rawData) - return buffer.getInt() == NEW_FORMAT_HEADER_IDENTIFIER - } - - /** - * Loads wrapped secret key from new binary format. - * - * The new format structure is: - * - Header identifier (4 bytes) - * - Metadata length (4 bytes) - * - Protobuf-serialized metadata - * - Raw key data - * - * @param rawData The raw binary data - * @return WrappedSecretKey instance - * @throws ClientException if the data format is invalid or parsing fails - */ - @Throws(IOException::class) - private fun loadFromNewFormat(rawData: ByteArray): WrappedSecretKey { - val methodTag = "$TAG:loadFromNewFormat" - Logger.info(methodTag, "Loading key using optimized new binary format") - val buffer = ByteBuffer.wrap(rawData) - - // Skip header identifier (already validated in isNewFormat) - buffer.getInt() - - // Read metadata length - val metadataLength = buffer.getInt() - - // Extract and parse protobuf metadata - val metadataBytes = ByteArray(metadataLength) - buffer.get(metadataBytes) - - val jsonString = String(metadataBytes, Charsets.UTF_8) - val json = JSONObject(jsonString) - val algorithm = json.getString("algorithm") - val cipherTransformation = json.getString("cipherTransformation") - val keyDataLength = json.getInt("keyDataLength") - - // Validate key data length - if (keyDataLength != buffer.remaining()) { - Logger.warn(methodTag, "Key data length mismatch. Expected: $keyDataLength, Actual: ${buffer.remaining()}") - } - - val keyBytes = ByteArray(buffer.remaining()) - buffer.get(keyBytes) - - Logger.verbose(methodTag, "Successfully loaded key with algorithm: $algorithm, transformation: $cipherTransformation") - return WrappedSecretKey(keyBytes, algorithm, cipherTransformation) - } - - /** - * Loads wrapped secret key from old binary format. - * Uses default values for algorithm and cipher transformation. - * - * @param rawData The raw key bytes - * @return WrappedSecretKey instance with default algorithm and cipher transformation - */ - private fun loadFromOldFormat(rawData: ByteArray): WrappedSecretKey { - val methodTag = "$TAG:loadFromOldFormat" - Logger.info(methodTag, "Loading key using old format with default algorithm and cipher transformation") - - return WrappedSecretKey( - wrappedKeyData = rawData, - algorithm = DEFAULT_ALGORITHM, - cipherTransformation = DEFAULT_CIPHER_TRANSFORMATION - ) - } - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt new file mode 100644 index 0000000000..c98b0ff7a2 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +/** + * Interface for serializing and deserializing WrappedSecretKey objects. + * + * Implementations can define different serialization formats and versions. + */ +interface IWrappedSecretKeySerializer { + + /** + * Serialize the given WrappedSecretKey into a byte array. + * + * @param wrappedSecretKey The WrappedSecretKey to serialize. + * @return The serialized byte array representation of the WrappedSecretKey. + */ + fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray + + /** + * Deserialize the given byte array into a WrappedSecretKey object. + * + * @param data The byte array to deserialize. + * @return The deserialized WrappedSecretKey object. + * @throws Exception if deserialization fails. + */ + fun deserialize(data: ByteArray): WrappedSecretKey + + /** + * Get the version of the serialization format used by this serializer. + * + * @return The version number as an integer. + */ + fun getVersion(): Int +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt new file mode 100644 index 0000000000..1aa9097592 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +import com.microsoft.identity.common.java.flighting.CommonFlight +import com.microsoft.identity.common.java.flighting.CommonFlightsManager + +/** + * Represents a wrapped secret key with cryptographic metadata. + * + * Encapsulates an encrypted secret key with algorithm and cipher transformation metadata. + * Supports multiple serialization formats controlled by flight configuration for backward compatibility. + * + * **Formats:** + * - Version 0: Legacy (raw key data only) + * - Version 1: JSON metadata format + * - Future versions: Extensible via flight config + * + * @property wrappedKeyData The encrypted secret key bytes + * @property algorithm The key algorithm (e.g., "AES") + * @property cipherTransformation The cipher transformation (e.g., "RSA/ECB/PKCS1Padding") + * + * @see WrappedSecretKeySerializerManager + * @see CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION + */ +data class WrappedSecretKey( + val wrappedKeyData: ByteArray, + val algorithm: String, + val cipherTransformation: String +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WrappedSecretKey + + if (!wrappedKeyData.contentEquals(other.wrappedKeyData)) return false + if (algorithm != other.algorithm) return false + if (cipherTransformation != other.cipherTransformation) return false + + return true + } + + override fun hashCode(): Int { + var result = wrappedKeyData.contentHashCode() + result = 31 * result + algorithm.hashCode() + result = 31 * result + cipherTransformation.hashCode() + return result + } + + /** + * Serializes the key using the format specified by flight configuration. + * + * @return Serialized byte array + */ + fun serialize(): ByteArray { + val serializerVersion = CommonFlightsManager.getFlightsProvider() + .getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) + return WrappedSecretKeySerializerManager + .getSerializer(serializerVersion) + .serialize(this) + } + + companion object { + /** + * Deserializes a wrapped key with automatic format detection. + * + * @param data The serialized byte data + * @return Reconstructed [WrappedSecretKey] + * @throws IllegalArgumentException if format is unsupported + */ + fun deserialize(data: ByteArray): WrappedSecretKey { + val serializerVersion = WrappedSecretKeySerializerManager.getVersion(data) + return WrappedSecretKeySerializerManager + .getSerializer(serializerVersion) + .deserialize(data) + } + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt new file mode 100644 index 0000000000..7a271fc7cd --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerManager.HEADER_ID_FIELD_SIZE_BYTES +import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerManager.METADATA_LENGTH_FIELD_SIZE_BYTES +import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.logging.Logger +import org.json.JSONObject +import java.nio.ByteBuffer + +/** + * JSON-based serializer for [WrappedSecretKey] that stores metadata in JSON format. + * + * This serializer implements version 1 of the wrapped secret key format, storing metadata + * as JSON alongside the raw wrapped key data. This format provides better structure and + * extensibility compared to the legacy format while maintaining human-readable metadata. + * + * **Format structure:** + * ``` + * [Header ID: 4 bytes][Metadata Length: 4 bytes][JSON Metadata][Raw Key Data] + * ``` + * + * **JSON Metadata fields:** + * - `algorithm`: The key algorithm (e.g., "AES") + * - `cipherTransformation`: The cipher transformation used (e.g., "RSA/ECB/PKCS1Padding") + * - `version`: Format version (1 for this serializer) + * - `keyDataLength`: Length of the raw key data for validation + * + * **Format characteristics:** + * - Version: 1 + * - Header identifier: 0x00FF3C01 + * - Metadata encoding: UTF-8 JSON + * - Supports backward compatibility validation + * - Human-readable metadata for debugging + * + * This serializer bridges the gap between the legacy format (version 0) and more + * advanced binary formats, providing structured metadata while maintaining readability. + * + * @see IWrappedSecretKeySerializer + * @see WrappedSecretKey + * @see WrappedSecretKeyLegacySerializer + */ +class WrappedSecretKeyJsonObjectSerializer: IWrappedSecretKeySerializer { + + companion object { + private const val TAG = "WrappedSecretKeyJsonObjectSerializer" + + /** Version identifier for the JSON-based format */ + const val VERSION = 1 + + /** Header identifier used to recognize this format */ + private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3C01 + } + + /** + * Serializes a [WrappedSecretKey] to the JSON metadata format. + * + * Creates a structured binary format that includes a header identifier, metadata length, + * JSON-encoded metadata, and the raw wrapped key data. The metadata includes algorithm, + * cipher transformation, version, and key data length for validation purposes. + * + * @param wrappedSecretKey The wrapped secret key to serialize + * @return Serialized byte array in the JSON metadata format + */ + override fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray { + // New format: Store metadata header + raw key data + val metadata = JSONObject().apply { + put("algorithm", wrappedSecretKey.algorithm) + put("cipherTransformation", wrappedSecretKey.cipherTransformation) + put("version", VERSION) + put("keyDataLength", wrappedSecretKey.wrappedKeyData.size) + } + + val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) + + // Use ByteBuffer for cleaner header writing + val bufferSize = HEADER_ID_FIELD_SIZE_BYTES + METADATA_LENGTH_FIELD_SIZE_BYTES + + metadataBytes.size + wrappedSecretKey.wrappedKeyData.size + return ByteBuffer.allocate(bufferSize) + .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Write header length (4 bytes, big-endian) + .putInt(metadataBytes.size) // Write metadata length (4 bytes, big-endian) + .put(metadataBytes) // Write metadata + .put(wrappedSecretKey.wrappedKeyData) // Write raw key data + .array() + } + + /** + * Deserializes byte data to a [WrappedSecretKey] from the JSON metadata format. + * + * Parses the structured binary format to extract the JSON metadata and raw key data. + * The method validates the metadata length and key data length for integrity checking. + * + * **Data format expected:** + * 1. Header identifier (4 bytes) - already validated by caller + * 2. Metadata length (4 bytes) - indicates JSON metadata size + * 3. JSON metadata (variable) - UTF-8 encoded JSON with key information + * 4. Raw key data (remaining bytes) - the actual wrapped key bytes + * + * @param data The serialized binary data containing header, metadata, and key data + * @return [WrappedSecretKey] instance reconstructed from the serialized data + * @throws ClientException if the data format is invalid, JSON parsing fails, or validation errors occur + */ + override fun deserialize(data: ByteArray): WrappedSecretKey { + val methodTag = "$TAG:loadFromNewFormat" + Logger.info(methodTag, "Loading key using JSON metadata format") + val buffer = ByteBuffer.wrap(data) + + // Skip header identifier (already validated in isNewFormat) + buffer.getInt() + + // Read metadata length + val metadataLength = buffer.getInt() + + // Extract and parse JSON metadata + val metadataBytes = ByteArray(metadataLength) + buffer.get(metadataBytes) + + val jsonString = String(metadataBytes, Charsets.UTF_8) + val json = JSONObject(jsonString) + val algorithm = json.getString("algorithm") + val cipherTransformation = json.getString("cipherTransformation") + val keyDataLength = json.getInt("keyDataLength") + + // Validate key data length + if (keyDataLength != buffer.remaining()) { + Logger.warn( + methodTag, + "Key data length mismatch. Expected: $keyDataLength, Actual: ${buffer.remaining()}" + ) + } + + val keyBytes = ByteArray(buffer.remaining()) + buffer.get(keyBytes) + + Logger.verbose( + methodTag, + "Successfully loaded key with algorithm: $algorithm, transformation: $cipherTransformation" + ) + return WrappedSecretKey(keyBytes, algorithm, cipherTransformation) + } + + /** + * Returns the version identifier for this serializer. + * + * @return Version 1, indicating the JSON metadata format + */ + override fun getVersion(): Int { + return VERSION + } +} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt new file mode 100644 index 0000000000..bb67caf841 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + + +/** + * Legacy serializer for [WrappedSecretKey] that maintains backward compatibility with the original format. + * + * This serializer handles the legacy format where only the raw wrapped key data is stored + * without any additional metadata such as algorithm or cipher transformation information. + * When deserializing legacy data, it applies default values for missing metadata to ensure + * proper key reconstruction. + * + * **Format characteristics:** + * - Version: 0 (legacy) + * - Data: Raw wrapped key bytes only + * - No metadata header + * - Default algorithm: AES + * - Default cipher transformation: RSA/ECB/PKCS1Padding + * + * This serializer is essential for maintaining compatibility when migrating from older + * key storage formats to newer metadata-aware formats. + * + * @see IWrappedSecretKeySerializer + * @see WrappedSecretKey + */ +class WrappedSecretKeyLegacySerializer : IWrappedSecretKeySerializer { + + companion object { + /** Version identifier for the legacy format */ + const val VERSION = 0 + + /** Default algorithm used when no metadata is available */ + private const val DEFAULT_ALGORITHM = "AES" + + /** Default cipher transformation used when no metadata is available */ + private const val DEFAULT_CIPHER_TRANSFORMATION = "RSA/ECB/PKCS1Padding" + } + + /** + * Serializes a [WrappedSecretKey] to the legacy format. + * + * In the legacy format, only the raw wrapped key data is stored without any metadata. + * This maintains compatibility with older storage implementations. + * + * @param wrappedSecretKey The wrapped secret key to serialize + * @return Raw wrapped key data as byte array + */ + override fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray { + return wrappedSecretKey.wrappedKeyData + } + + /** + * Deserializes byte data to a [WrappedSecretKey] using legacy format assumptions. + * + * Since the legacy format doesn't include metadata, this method applies default + * values for algorithm and cipher transformation to reconstruct a complete + * [WrappedSecretKey] instance. + * + * @param data Raw wrapped key data from legacy format + * @return [WrappedSecretKey] instance with legacy data and default metadata + */ + override fun deserialize(data: ByteArray): WrappedSecretKey { + // Legacy format does not include metadata, use defaults + return WrappedSecretKey( + wrappedKeyData = data, + algorithm = DEFAULT_ALGORITHM, + cipherTransformation = DEFAULT_CIPHER_TRANSFORMATION + ) + } + + /** + * Returns the version identifier for this serializer. + * + * @return Version 0, indicating the legacy format + */ + override fun getVersion(): Int { + return VERSION + } +} \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt new file mode 100644 index 0000000000..5ff5ae7588 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +import java.nio.ByteBuffer + +/** + * Manager for handling different versions of [WrappedSecretKey] serialization formats. + * + * This object provides centralized management for serializing and deserializing wrapped secret keys + * across different format versions. It handles version detection, serializer selection, and + * maintains backward compatibility between legacy and modern formats. + * + * **Supported formats:** + * - **Version 0 (Legacy)**: Raw key data only, no metadata or header + * - **Version 1 (JSON)**: Header + JSON metadata + raw key data + * - **Future versions**: Extensible design for new serialization formats + * + * **Header structure for new formats:** + * ``` + * [Header ID: 4 bytes][Metadata Length: 4 bytes][Metadata][Raw Key Data] + * ``` + * + * **Version encoding:** + * The header identifier uses the first 3 bytes (0x00FF3C) as a format identifier, + * with the last byte encoding the version number (0x00-0xFF), allowing for 256 different versions. + * + * **Version detection algorithm:** + * 1. Check if data has minimum header size + * 2. Extract header identifier (first 4 bytes) + * 3. Mask and compare first 3 bytes against known format identifier + * 4. Extract version from last byte, or assume legacy format (version 0) + * + * @see IWrappedSecretKeySerializer + * @see WrappedSecretKey + * @see WrappedSecretKeyLegacySerializer + * @see WrappedSecretKeyJsonObjectSerializer + */ +object WrappedSecretKeySerializerManager { + /** Size in bytes for the header identifier field */ + const val HEADER_ID_FIELD_SIZE_BYTES = Int.SIZE_BYTES + + /** Size in bytes for the metadata length field */ + const val METADATA_LENGTH_FIELD_SIZE_BYTES = Int.SIZE_BYTES + + /** Base header identifier for new format (first 3 bytes), with version byte as 0x00 */ + private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3C00 + + /** Mask to isolate the first 3 bytes of the header for format identification */ + private const val NEW_FORMAT_HEADER_MASK = 0xFFFFFF00 // Mask to compare only first 3 bytes + + /** Mask to extract the version byte (last byte) from the header identifier */ + private const val VERSION_BYTE_MASK = 0x000000FF + + /** + * Extracts the serializer version from the header of wrapped key data. + * + * The version is stored in the last byte of the 4-byte header identifier. + * This allows for backward compatibility when introducing new serialization formats. + * + * **Version detection logic:** + * 1. If data is too small for a header, assume legacy format (version 0) + * 2. Extract the 4-byte header identifier + * 3. Use bit masking to compare only the first 3 bytes against the known format identifier + * 4. If matched, extract the version from the last byte; otherwise, return legacy version + * + * @param rawData The raw bytes read from file containing the wrapped key data + * @return The version number (0-255) if the data uses the new format, 0 if legacy format or invalid data + */ + fun getVersion(rawData: ByteArray): Int { + if ((rawData.size < HEADER_ID_FIELD_SIZE_BYTES + METADATA_LENGTH_FIELD_SIZE_BYTES)) { + return WrappedSecretKeyLegacySerializer.VERSION + } + val buffer = ByteBuffer.wrap(rawData) + val headerValue = buffer.getInt() + // Mask out the version byte (last byte) and compare only the first 3 bytes + return if ((headerValue and NEW_FORMAT_HEADER_MASK.toInt()) == NEW_FORMAT_HEADER_IDENTIFIER) { + headerValue and VERSION_BYTE_MASK // Return the version byte + } else { + WrappedSecretKeyLegacySerializer.VERSION // Legacy format + } + } + + /** + * Returns the appropriate serializer instance for the specified version. + * + * Creates and returns a serializer that can handle the specified format version. + * This factory method ensures that the correct serialization strategy is used + * for each supported format version. + * + * **Supported versions:** + * - Version 0: [WrappedSecretKeyLegacySerializer] for legacy format + * - Version 1: [WrappedSecretKeyJsonObjectSerializer] for JSON metadata format + * + * @param version The serialization format version number + * @return An [IWrappedSecretKeySerializer] instance capable of handling the specified version + * @throws IllegalArgumentException if the version is not supported + */ + fun getSerializer(version: Int): IWrappedSecretKeySerializer { + return when (version) { + WrappedSecretKeyJsonObjectSerializer.VERSION -> WrappedSecretKeyJsonObjectSerializer() + WrappedSecretKeyLegacySerializer.VERSION -> WrappedSecretKeyLegacySerializer() + else -> throw IllegalArgumentException("Unsupported WrappedSecretKey version: $version") + } + } +} \ No newline at end of file diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index 4dd7cbf9f7..9f0942c59c 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -22,7 +22,8 @@ // THE SOFTWARE. package com.microsoft.identity.common.crypto -import com.microsoft.identity.common.java.exception.ClientException +import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKey +import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerManager import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager import io.mockk.every @@ -32,27 +33,21 @@ import org.json.JSONObject import org.junit.After import org.junit.Assert.* import org.junit.Before -import org.junit.Rule import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File import java.nio.ByteBuffer class WrappedSecretKeyTest { - @get:Rule - val tempFolder = TemporaryFolder() - private val testKeyBytes = "test-key-data-12345".toByteArray() private val testAlgorithm = "AES" private val testCipherTransformation = "RSA/ECB/PKCS1Padding" - companion object{ + companion object { // New format constants matching the implementation - private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF12AB + private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3C01 private const val FORMAT_VERSION_1 = 1 - } + @Before fun setUp() { mockkObject(CommonFlightsManager) @@ -73,96 +68,66 @@ class WrappedSecretKeyTest { } @Test - fun loadFromFileReturnsNullWhenFileDoesNotExist() { - val nonExistentFile = File("non-existent-file.dat") - - val result = WrappedSecretKey.loadFromFile(nonExistentFile, 1024) - - assertNull(result) - } - - @Test - fun loadFromFileReturnsNullWhenFileIsEmpty() { - val testFile = tempFolder.newFile("empty-file.dat") - - val result = WrappedSecretKey.loadFromFile(testFile, 1024) - - assertNull(result) - } - - @Test - fun roundTripStoreAndLoadWithNewFormatPreservesData() { - val testFile = tempFolder.newFile("test-new-format.dat") + fun serializeAndDeserializeWithNewFormatPreservesData() { val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - // Mock flight to enable new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + // Mock flight to enable new format (version 1) + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 - // Store with new format (controlled by flight) - originalKey.storeOnFile(testFile) + // Serialize and deserialize + val serializedData = originalKey.serialize() + val deserializedKey = WrappedSecretKey.deserialize(serializedData) - // Load back - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) - - assertNotNull(loadedKey) - assertArrayEquals(originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) - assertEquals(originalKey.algorithm, loadedKey.algorithm) - assertEquals(originalKey.cipherTransformation, loadedKey.cipherTransformation) + assertArrayEquals(originalKey.wrappedKeyData, deserializedKey.wrappedKeyData) + assertEquals(originalKey.algorithm, deserializedKey.algorithm) + assertEquals(originalKey.cipherTransformation, deserializedKey.cipherTransformation) } @Test - fun roundTripStoreAndLoadWithOldFormatPreservesKeyData() { - val testFile = tempFolder.newFile("test-old-format.dat") + fun serializeAndDeserializeWithLegacyFormatPreservesKeyData() { val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - // Mock flight to disable new format (use old format) - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false - - // Store with old format (controlled by flight) - originalKey.storeOnFile(testFile) + // Mock flight to use legacy format (version 0) + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 0 - // Load back (should use default values for algorithm and cipher) - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + // Serialize and deserialize + val serializedData = originalKey.serialize() + val deserializedKey = WrappedSecretKey.deserialize(serializedData) - assertNotNull(loadedKey) - assertArrayEquals(originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) - assertEquals("AES", loadedKey.algorithm) // Default value - assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) // Default value + assertArrayEquals(originalKey.wrappedKeyData, deserializedKey.wrappedKeyData) + assertEquals("AES", deserializedKey.algorithm) // Default value for legacy format + assertEquals("RSA/ECB/PKCS1Padding", deserializedKey.cipherTransformation) // Default value for legacy format } @Test - fun newFormatStoresLargerFileThanOldFormat() { - val newFormatFile = tempFolder.newFile("new-format.dat") - val oldFormatFile = tempFolder.newFile("old-format.dat") + fun newFormatCreatesLargerDataThanLegacyFormat() { val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) // Test new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true - wrappedKey.storeOnFile(newFormatFile) + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 + val newFormatData = wrappedKey.serialize() - // Test old format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false - wrappedKey.storeOnFile(oldFormatFile) + // Test legacy format + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 0 + val legacyFormatData = wrappedKey.serialize() - assertTrue("New format should create larger file due to metadata", - newFormatFile.length() > oldFormatFile.length()) - assertEquals("Old format should only contain key bytes", - testKeyBytes.size.toLong(), oldFormatFile.length()) + assertTrue("New format should create larger data due to metadata", + newFormatData.size > legacyFormatData.size) + assertEquals("Legacy format should only contain key bytes", + testKeyBytes.size, legacyFormatData.size) } @Test - fun newFormatFileContainsValidBinaryStructure() { - val testFile = tempFolder.newFile("binary-structure.dat") + fun newFormatContainsValidBinaryStructure() { val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) // Mock flight to enable new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 - wrappedKey.storeOnFile(testFile) + val serializedData = wrappedKey.serialize() // Read and verify the binary structure - val fileBytes = testFile.readBytes() - val buffer = ByteBuffer.wrap(fileBytes) + val buffer = ByteBuffer.wrap(serializedData) // Verify header identifier (4 bytes) val headerIdentifier = buffer.getInt() @@ -170,7 +135,7 @@ class WrappedSecretKeyTest { // Verify metadata length (4 bytes) val metadataLength = buffer.getInt() - assertTrue("Metadata length should be reasonable", metadataLength > 0 && metadataLength < fileBytes.size) + assertTrue("Metadata length should be reasonable", metadataLength > 0 && metadataLength < serializedData.size) // Read and verify metadata val metadataBytes = ByteArray(metadataLength) @@ -190,209 +155,139 @@ class WrappedSecretKeyTest { @Test fun largeKeyDataIsHandledCorrectly() { - val testFile = tempFolder.newFile("large-key.dat") val largeKeyData = ByteArray(8192) { it.toByte() } // 8KB test data val wrappedKey = WrappedSecretKey(largeKeyData, testAlgorithm, testCipherTransformation) // Mock flight to enable new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 - // Store and load - wrappedKey.storeOnFile(testFile) - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 10240) + // Serialize and deserialize + val serializedData = wrappedKey.serialize() + val deserializedKey = WrappedSecretKey.deserialize(serializedData) - assertNotNull(loadedKey) - assertArrayEquals(largeKeyData, loadedKey!!.wrappedKeyData) - assertEquals(testAlgorithm, loadedKey.algorithm) - assertEquals(testCipherTransformation, loadedKey.cipherTransformation) + assertArrayEquals(largeKeyData, deserializedKey.wrappedKeyData) + assertEquals(testAlgorithm, deserializedKey.algorithm) + assertEquals(testCipherTransformation, deserializedKey.cipherTransformation) } @Test - fun oldFormatDetectionWorksWithBinaryData() { - val testFile = tempFolder.newFile("binary-data.dat") + fun legacyFormatDetectionWorksWithBinaryData() { val binaryData = ByteArray(256) { (it % 256).toByte() } // Random binary data - // Write raw binary data (simulating old format) - testFile.writeBytes(binaryData) + // Deserialize raw binary data (simulating legacy format) + val deserializedKey = WrappedSecretKey.deserialize(binaryData) - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) - - assertNotNull(loadedKey) - assertArrayEquals(binaryData, loadedKey!!.wrappedKeyData) - assertEquals("AES", loadedKey.algorithm) - assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + assertArrayEquals(binaryData, deserializedKey.wrappedKeyData) + assertEquals("AES", deserializedKey.algorithm) + assertEquals("RSA/ECB/PKCS1Padding", deserializedKey.cipherTransformation) } @Test - fun corruptedNewFormatFallsBackToOldFormat() { - val testFile = tempFolder.newFile("corrupted.dat") - + fun corruptedNewFormatFallsBackToLegacyFormat() { // Create invalid new format data (wrong header identifier) val invalidData = ByteBuffer.allocate(20) .putInt(0x12345678) // Wrong header identifier .putInt(100) // Metadata length larger than available data .put("invalid".toByteArray()) .array() - testFile.writeBytes(invalidData) - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) + val deserializedKey = WrappedSecretKey.deserialize(invalidData) - assertNotNull(loadedKey) - assertArrayEquals(invalidData, loadedKey!!.wrappedKeyData) // Should load as raw bytes (old format) - assertEquals("AES", loadedKey.algorithm) - assertEquals("RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + assertArrayEquals(invalidData, deserializedKey.wrappedKeyData) // Should load as raw bytes (legacy format) + assertEquals("AES", deserializedKey.algorithm) + assertEquals("RSA/ECB/PKCS1Padding", deserializedKey.cipherTransformation) } @Test - fun storeOnFileUsesOldFormatWhenFlightDisabled() { - val testFile = tempFolder.newFile("flight-disabled.dat") + fun serializeUsesLegacyFormatWhenFlightIsZero() { val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - // Mock flight to disable new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false + // Mock flight to use legacy format + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 0 - wrappedKey.storeOnFile(testFile) + val serializedData = wrappedKey.serialize() - // Verify old format: file should only contain raw key bytes - val fileBytes = testFile.readBytes() - assertArrayEquals(testKeyBytes, fileBytes) + // Verify legacy format: data should only contain raw key bytes + assertArrayEquals(testKeyBytes, serializedData) } @Test - fun storeOnFileUsesNewFormatWhenFlightEnabled() { - val testFile = tempFolder.newFile("flight-enabled.dat") + fun serializeUsesNewFormatWhenFlightIsOne() { val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) // Mock flight to enable new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 - wrappedKey.storeOnFile(testFile) + val serializedData = wrappedKey.serialize() - // Verify new format: file should be larger than just key bytes - val fileBytes = testFile.readBytes() - assertTrue("New format file should be larger than raw key", fileBytes.size > testKeyBytes.size) + // Verify new format: data should be larger than just key bytes + assertTrue("New format data should be larger than raw key", serializedData.size > testKeyBytes.size) - // Verify it can be loaded correctly - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) - assertNotNull(loadedKey) - assertArrayEquals(testKeyBytes, loadedKey!!.wrappedKeyData) - assertEquals(testAlgorithm, loadedKey.algorithm) - assertEquals(testCipherTransformation, loadedKey.cipherTransformation) + // Verify it can be deserialized correctly + val deserializedKey = WrappedSecretKey.deserialize(serializedData) + assertArrayEquals(testKeyBytes, deserializedKey.wrappedKeyData) + assertEquals(testAlgorithm, deserializedKey.algorithm) + assertEquals(testCipherTransformation, deserializedKey.cipherTransformation) } /** - * Test that verifies backward compatibility when rolling back the ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT flight. - * This test ensures that keys stored with the new format can still be read when the flight is disabled, - * which is critical for production rollback scenarios. + * Test backward compatibility: key serialized with new format can be read when flight is changed back. */ @Test - fun flightRollbackScenario_keyStoredWithNewFormatCanBeReadWithFlightDisabled() { - val testFile = tempFolder.newFile("rollback-scenario.dat") + fun testBackwardCompatibility_NewFormatToLegacy() { val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - // Step 1: Enable flight and store key using new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true - - originalKey.storeOnFile(testFile) - - // Verify the file was created with new format (contains metadata) - val fileBytes = testFile.readBytes() - assertTrue("File should be larger than raw key bytes due to metadata header", - fileBytes.size > testKeyBytes.size) - - // Verify it can be read with flight enabled - val keyWithFlightEnabled = WrappedSecretKey.loadFromFile(testFile, 1024) - assertNotNull("Key should be readable with flight enabled", keyWithFlightEnabled) - assertArrayEquals("Key data should match", originalKey.wrappedKeyData, keyWithFlightEnabled!!.wrappedKeyData) - assertEquals("Algorithm should match", originalKey.algorithm, keyWithFlightEnabled.algorithm) - assertEquals("Cipher transformation should match", originalKey.cipherTransformation, keyWithFlightEnabled.cipherTransformation) - - // Step 2: Disable flight (simulate rollback) and verify key can still be read - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false - - val keyAfterRollback = WrappedSecretKey.loadFromFile(testFile, 1024) - assertNotNull("Key should still be readable after flight rollback", keyAfterRollback) - assertArrayEquals("Key data should remain intact after rollback", originalKey.wrappedKeyData, keyAfterRollback!!.wrappedKeyData) - assertEquals("Algorithm should be preserved from metadata", originalKey.algorithm, keyAfterRollback.algorithm) - assertEquals("Cipher transformation should be preserved from metadata", originalKey.cipherTransformation, keyAfterRollback.cipherTransformation) - - // Verify that the format detection correctly identifies this as new format - // even when the flight is disabled (loadFromFile should still work) - assertEquals("Both reads should return identical key data", - keyWithFlightEnabled.wrappedKeyData.contentToString(), - keyAfterRollback.wrappedKeyData.contentToString()) - assertEquals("Both reads should return identical algorithm", - keyWithFlightEnabled.algorithm, - keyAfterRollback.algorithm) - assertEquals("Both reads should return identical cipher transformation", - keyWithFlightEnabled.cipherTransformation, - keyAfterRollback.cipherTransformation) + // Phase 1: Serialize with new format (version 1) + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 + val serializedData = originalKey.serialize() + + // Validate key is serialized with new format + val buffer = ByteBuffer.wrap(serializedData) + val headerIdentifier = buffer.getInt() + assertEquals("Key should be serialized with new format", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) + + // Phase 2: Deserialize (automatic format detection should work regardless of flight value) + val deserializedKey = WrappedSecretKey.deserialize(serializedData) + assertArrayEquals("Key data should be preserved", originalKey.wrappedKeyData, deserializedKey.wrappedKeyData) + assertEquals("Algorithm should be preserved from metadata", originalKey.algorithm, deserializedKey.algorithm) + assertEquals("Cipher transformation should be preserved from metadata", originalKey.cipherTransformation, deserializedKey.cipherTransformation) } /** - * Test that verifies forward compatibility when enabling the ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT flight. - * This test ensures that keys stored with the old format can still be read when the flight is enabled, - * which is important when rolling out the new feature. + * Test forward compatibility: key serialized with legacy format can be read when flight is enabled. */ @Test - fun flightRolloutScenario_keyStoredWithOldFormatCanBeReadWithFlightEnabled() { - val testFile = tempFolder.newFile("rollout-scenario.dat") + fun testForwardCompatibility_LegacyToNewFormat() { val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - // Step 1: Disable flight and store key using old format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false - - originalKey.storeOnFile(testFile) - - // Verify the file was created with old format (only raw key bytes) - val fileBytes = testFile.readBytes() - assertArrayEquals("Old format should contain only raw key bytes", testKeyBytes, fileBytes) - - // Verify it can be read with flight disabled (uses default algorithm and cipher) - val keyWithFlightDisabled = WrappedSecretKey.loadFromFile(testFile, 1024) - assertNotNull("Key should be readable with flight disabled", keyWithFlightDisabled) - assertArrayEquals("Key data should match", originalKey.wrappedKeyData, keyWithFlightDisabled!!.wrappedKeyData) - assertEquals("Should use default algorithm", "AES", keyWithFlightDisabled.algorithm) - assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", keyWithFlightDisabled.cipherTransformation) - - // Step 2: Enable flight (simulate rollout) and verify key can still be read - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true - - val keyAfterRollout = WrappedSecretKey.loadFromFile(testFile, 1024) - assertNotNull("Key should still be readable after flight rollout", keyAfterRollout) - assertArrayEquals("Key data should remain intact after rollout", originalKey.wrappedKeyData, keyAfterRollout!!.wrappedKeyData) - assertEquals("Should still use default algorithm for old format", "AES", keyAfterRollout.algorithm) - assertEquals("Should still use default cipher transformation for old format", "RSA/ECB/PKCS1Padding", keyAfterRollout.cipherTransformation) - - // Verify that both reads return the same data (backward compatibility maintained) - assertEquals("Both reads should return identical key data", - keyWithFlightDisabled.wrappedKeyData.contentToString(), - keyAfterRollout.wrappedKeyData.contentToString()) - assertEquals("Both reads should return identical algorithm", - keyWithFlightDisabled.algorithm, - keyAfterRollout.algorithm) - assertEquals("Both reads should return identical cipher transformation", - keyWithFlightDisabled.cipherTransformation, - keyAfterRollout.cipherTransformation) + // Phase 1: Serialize with legacy format (version 0) + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 0 + val serializedData = originalKey.serialize() + + // Validate key is serialized with legacy format + assertArrayEquals("Key should be serialized with legacy format", testKeyBytes, serializedData) + + // Phase 2: Deserialize (should work regardless of current flight value) + val deserializedKey = WrappedSecretKey.deserialize(serializedData) + assertArrayEquals("Key data should be preserved", originalKey.wrappedKeyData, deserializedKey.wrappedKeyData) + assertEquals("Should use default algorithm for legacy format", "AES", deserializedKey.algorithm) + assertEquals("Should use default cipher transformation for legacy format", "RSA/ECB/PKCS1Padding", deserializedKey.cipherTransformation) } /** - * Test that validates the wrapped key is using the new format when the flight is enabled. - * This test verifies that the stored key contains the proper binary structure and metadata. + * Test that validates serialized data uses new format structure. */ @Test - fun validateNewFormatKeyStructure() { - val testFile = tempFolder.newFile("new-format-validation.dat") + fun validateNewFormatStructure() { val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - // Enable new format flight - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + // Enable new format + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 - wrappedKey.storeOnFile(testFile) + val serializedData = wrappedKey.serialize() - // Validate the file structure manually - val fileBytes = testFile.readBytes() - val buffer = ByteBuffer.wrap(fileBytes) + // Validate the structure manually + val buffer = ByteBuffer.wrap(serializedData) // Check header identifier val headerIdentifier = buffer.getInt() @@ -416,162 +311,99 @@ class WrappedSecretKeyTest { } /** - * Test that validates the wrapped key is using the legacy (old) format when the flight is disabled. - * This test verifies that the stored key contains only raw key bytes without metadata. + * Test that validates serialized data uses legacy format structure. */ @Test - fun validateLegacyFormatKeyStructure() { - val testFile = tempFolder.newFile("legacy-format-validation.dat") + fun validateLegacyFormatStructure() { val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - // Disable new format flight - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false - - wrappedKey.storeOnFile(testFile) - - // Validate the file contains only raw key bytes - val fileBytes = testFile.readBytes() - assertArrayEquals("Legacy format should contain only raw key bytes", testKeyBytes, fileBytes) - assertEquals("Legacy format file size should equal key size", testKeyBytes.size.toLong(), testFile.length()) - } - - /** - * Test that creates a key with new format enabled, then disables the flight and reads the key. - * This simulates a rollback scenario where the key should still be readable. - */ - @Test - fun testBackwardCompatibility_NewFormatToLegacy() { - val testFile = tempFolder.newFile("backward-compatibility.dat") - val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) + // Use legacy format + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 0 - // Phase 1: Enable flight and create key with new format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true - originalKey.storeOnFile(testFile) + val serializedData = wrappedKey.serialize() - // Validate key is created with new format - val fileBytes = testFile.readBytes() - val buffer = ByteBuffer.wrap(fileBytes) - val headerIdentifier = buffer.getInt() - assertEquals("Key should be created with new format", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) - - // Phase 2: Disable flight and read the key (rollback scenario) - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false - - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) - assertNotNull("Key should still be readable after flight rollback", loadedKey) - assertArrayEquals("Key data should be preserved", originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) - assertEquals("Algorithm should be preserved from metadata", originalKey.algorithm, loadedKey.algorithm) - assertEquals("Cipher transformation should be preserved from metadata", originalKey.cipherTransformation, loadedKey.cipherTransformation) + // Validate contains only raw key bytes + assertArrayEquals("Legacy format should contain only raw key bytes", testKeyBytes, serializedData) + assertEquals("Legacy format size should equal key size", testKeyBytes.size, serializedData.size) } /** - * Test that creates a key with legacy format, then enables the flight and reads the key. - * This simulates a rollout scenario where existing legacy keys should still be readable. + * Test version detection from serialized data. */ @Test - fun testForwardCompatibility_LegacyToNewFormat() { - val testFile = tempFolder.newFile("forward-compatibility.dat") - val originalKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - - // Phase 1: Disable flight and create key with legacy format - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns false - originalKey.storeOnFile(testFile) - - // Validate key is created with legacy format - val fileBytes = testFile.readBytes() - assertArrayEquals("Key should be created with legacy format", testKeyBytes, fileBytes) - - // Phase 2: Enable flight and read the key (rollout scenario) - every { CommonFlightsManager.getFlightsProvider().isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT) } returns true + fun testVersionDetection() { + val wrappedKey = WrappedSecretKey(testKeyBytes, testAlgorithm, testCipherTransformation) - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) - assertNotNull("Legacy key should still be readable after flight rollout", loadedKey) - assertArrayEquals("Key data should be preserved", originalKey.wrappedKeyData, loadedKey!!.wrappedKeyData) - assertEquals("Should use default algorithm for legacy format", "AES", loadedKey.algorithm) - assertEquals("Should use default cipher transformation for legacy format", "RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + // Test new format version detection + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 + val newFormatData = wrappedKey.serialize() + val detectedNewVersion = WrappedSecretKeySerializerManager.getVersion(newFormatData) + assertEquals("Should detect version 1 for new format", 1, detectedNewVersion) + + // Test legacy format version detection + every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 0 + val legacyFormatData = wrappedKey.serialize() + val detectedLegacyVersion = WrappedSecretKeySerializerManager.getVersion(legacyFormatData) + assertEquals("Should detect version 0 for legacy format", 0, detectedLegacyVersion) + + // Test raw binary data (should be detected as legacy) + val rawData = "random-binary-data".toByteArray() + val detectedRawVersion = WrappedSecretKeySerializerManager.getVersion(rawData) + assertEquals("Should detect version 0 for raw data", 0, detectedRawVersion) } /** - * Test edge case where metadata length is corrupted but header identifier is correct. - * Since the header identifier matches new format, it should throw an exception rather than fall back. + * Test edge case where metadata has corrupted length in new format. */ - @Test + @Test(expected = IllegalArgumentException::class) fun corruptedMetadataLengthThrowsException() { - val testFile = tempFolder.newFile("corrupted-metadata-length.dat") - // Create new format with corrupted metadata length val corruptedData = ByteBuffer.allocate(16) .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Correct header identifier .putInt(-1) // Invalid metadata length (negative) .put("test".toByteArray()) .array() - testFile.writeBytes(corruptedData) - - // Should throw ClientException because it detects new format but has invalid metadata length - try { - WrappedSecretKey.loadFromFile(testFile, 1024) - fail("Should have thrown ClientException for corrupted new format") - } catch (e: ClientException) { - assertEquals("Should throw KEY_LOAD_FAILURE error", - ClientException.KEY_LOAD_FAILURE, - e.errorCode) - - } + + // Should throw exception because it detects new format but has invalid metadata length + WrappedSecretKey.deserialize(corruptedData) } /** - * Test edge case where metadata length is larger than remaining buffer size. + * Test edge case where metadata length exceeds remaining data. */ - @Test + @Test(expected = IllegalArgumentException::class) fun metadataLengthLargerThanRemainingDataThrowsException() { - val testFile = tempFolder.newFile("metadata-too-large.dat") - // Create new format with metadata length larger than remaining data val corruptedData = ByteBuffer.allocate(16) .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Correct header identifier .putInt(1000) // Metadata length larger than remaining data .put("test".toByteArray()) .array() - testFile.writeBytes(corruptedData) - - // Should throw ClientException because metadata length exceeds remaining buffer - try { - WrappedSecretKey.loadFromFile(testFile, 1024) - fail("Should have thrown ClientException for metadata length exceeding buffer size") - } catch (e: ClientException) { - assertEquals("Should throw KEY_LOAD_FAILURE error", - ClientException.KEY_LOAD_FAILURE, - e.errorCode) - } + + // Should throw exception because metadata length exceeds remaining buffer + WrappedSecretKey.deserialize(corruptedData) } /** - * Test that verifies empty file handling. + * Test very small data handling (less than header size). */ @Test - fun emptyFileReturnsNull() { - val testFile = tempFolder.newFile("empty.dat") - // File exists but is empty + fun smallDataIsDetectedAsLegacyFormat() { + val smallData = "test".toByteArray() // Less than 8 bytes needed for header - val result = WrappedSecretKey.loadFromFile(testFile, 1024) + val deserializedKey = WrappedSecretKey.deserialize(smallData) - assertNull("Empty file should return null", result) + assertArrayEquals("Should load as raw bytes", smallData, deserializedKey.wrappedKeyData) + assertEquals("Should use default algorithm", "AES", deserializedKey.algorithm) + assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", deserializedKey.cipherTransformation) } /** - * Test that verifies very small file handling (less than header size). + * Test empty data handling. */ - @Test - fun smallFileIsDetectedAsOldFormat() { - val testFile = tempFolder.newFile("small.dat") - val smallData = "test".toByteArray() // Less than 8 bytes needed for header - testFile.writeBytes(smallData) - - val loadedKey = WrappedSecretKey.loadFromFile(testFile, 1024) - - assertNotNull("Small file should be treated as old format", loadedKey) - assertArrayEquals("Should load as raw bytes", smallData, loadedKey!!.wrappedKeyData) - assertEquals("Should use default algorithm", "AES", loadedKey.algorithm) - assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", loadedKey.cipherTransformation) + @Test(expected = IllegalArgumentException::class) + fun emptyDataThrowsException() { + val emptyData = ByteArray(0) + WrappedSecretKey.deserialize(emptyData) } } diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index 0799e5f2ee..14a42ca671 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -132,9 +132,9 @@ public enum CommonFlight implements IFlightConfig { ENABLE_KEYSTORE_BACKED_SECRET_KEY_PROVIDER("EnableKeyStoreBackedSecretKeyProvider", false), /** - * Flight to enable the new wrapped secret key format. + * Flight to control the WrappedSecretKey serializer version */ - ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT("EnableNewWrappedSecretKeyFormat", false), + WRAPPED_SECRET_KEY_SERIALIZER_VERSION("WrappedSecretKeySerializerVersion", 0), /** * Flight to enable handling the UI in edge to edge mode From 4ec9e27cd29a31fcdb020ea073dab13a0ddb4fd8 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 4 Sep 2025 11:18:49 -0700 Subject: [PATCH 42/51] refactor: Update exception handling in WrappedSecretKey tests and add logging in serializer manager --- .../WrappedSecretKeySerializerManager.kt | 7 +++++++ .../identity/common/crypto/WrappedSecretKeyTest.kt | 14 +++----------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt index 5ff5ae7588..9561505da7 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt @@ -22,6 +22,7 @@ // THE SOFTWARE. package com.microsoft.identity.common.crypto.wrappedsecretkey +import com.microsoft.identity.common.logging.Logger import java.nio.ByteBuffer /** @@ -57,6 +58,7 @@ import java.nio.ByteBuffer * @see WrappedSecretKeyJsonObjectSerializer */ object WrappedSecretKeySerializerManager { + private const val TAG = "WrappedSecretKeySerializerManager" /** Size in bytes for the header identifier field */ const val HEADER_ID_FIELD_SIZE_BYTES = Int.SIZE_BYTES @@ -88,7 +90,9 @@ object WrappedSecretKeySerializerManager { * @return The version number (0-255) if the data uses the new format, 0 if legacy format or invalid data */ fun getVersion(rawData: ByteArray): Int { + val methodTag = "$TAG:getVersion" if ((rawData.size < HEADER_ID_FIELD_SIZE_BYTES + METADATA_LENGTH_FIELD_SIZE_BYTES)) { + Logger.warn(methodTag, "Data too small to contain header, assuming legacy format") return WrappedSecretKeyLegacySerializer.VERSION } val buffer = ByteBuffer.wrap(rawData) @@ -97,6 +101,7 @@ object WrappedSecretKeySerializerManager { return if ((headerValue and NEW_FORMAT_HEADER_MASK.toInt()) == NEW_FORMAT_HEADER_IDENTIFIER) { headerValue and VERSION_BYTE_MASK // Return the version byte } else { + Logger.warn(methodTag, "Data does not match known format identifier, assuming legacy format") WrappedSecretKeyLegacySerializer.VERSION // Legacy format } } @@ -117,6 +122,8 @@ object WrappedSecretKeySerializerManager { * @throws IllegalArgumentException if the version is not supported */ fun getSerializer(version: Int): IWrappedSecretKeySerializer { + val methodTag = "$TAG:getSerializer" + Logger.info(methodTag, "Getting serializer for version: $version") return when (version) { WrappedSecretKeyJsonObjectSerializer.VERSION -> WrappedSecretKeyJsonObjectSerializer() WrappedSecretKeyLegacySerializer.VERSION -> WrappedSecretKeyLegacySerializer() diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index 9f0942c59c..1b0c9d7e4c 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -34,6 +34,7 @@ import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test +import java.nio.BufferUnderflowException import java.nio.ByteBuffer class WrappedSecretKeyTest { @@ -355,7 +356,7 @@ class WrappedSecretKeyTest { /** * Test edge case where metadata has corrupted length in new format. */ - @Test(expected = IllegalArgumentException::class) + @Test(expected = NegativeArraySizeException::class) fun corruptedMetadataLengthThrowsException() { // Create new format with corrupted metadata length val corruptedData = ByteBuffer.allocate(16) @@ -371,7 +372,7 @@ class WrappedSecretKeyTest { /** * Test edge case where metadata length exceeds remaining data. */ - @Test(expected = IllegalArgumentException::class) + @Test(expected = BufferUnderflowException::class) fun metadataLengthLargerThanRemainingDataThrowsException() { // Create new format with metadata length larger than remaining data val corruptedData = ByteBuffer.allocate(16) @@ -397,13 +398,4 @@ class WrappedSecretKeyTest { assertEquals("Should use default algorithm", "AES", deserializedKey.algorithm) assertEquals("Should use default cipher transformation", "RSA/ECB/PKCS1Padding", deserializedKey.cipherTransformation) } - - /** - * Test empty data handling. - */ - @Test(expected = IllegalArgumentException::class) - fun emptyDataThrowsException() { - val emptyData = ByteArray(0) - WrappedSecretKey.deserialize(emptyData) - } } From e659bd8299f93a1e4b3a7710b050f6c04a1a6f41 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 4 Sep 2025 12:30:11 -0700 Subject: [PATCH 43/51] refactor: Rename variable rawData to wrappedSecretKeyData for clarity in KeyStoreBackedSecretKeyProvider --- .../common/crypto/KeyStoreBackedSecretKeyProvider.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index f5ddf189e5..eaef286b24 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -517,11 +517,11 @@ class KeyStoreBackedSecretKeyProvider( Logger.warn(methodTag, "Key file does not exist") return null } - val rawData = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) - if (rawData == null || rawData.isEmpty()) { + val wrappedSecretKeyData = FileUtil.readFromFile(keyFile, KEY_FILE_SIZE) + if (wrappedSecretKeyData == null || wrappedSecretKeyData.isEmpty()) { Logger.warn(methodTag, "Key file is empty") return null } - return WrappedSecretKey.deserialize(rawData) + return WrappedSecretKey.deserialize(wrappedSecretKeyData) } } From 2c19f96f395638a1459ce869d2fd3b6db767096c Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 4 Sep 2025 12:48:34 -0700 Subject: [PATCH 44/51] no longer suuport API 22 --- .../crypto/CryptoParameterSpecFactoryTest.kt | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt index e77feff665..a8830402b2 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/CryptoParameterSpecFactoryTest.kt @@ -208,32 +208,7 @@ class CryptoParameterSpecFactoryTest { Assert.assertEquals("legacy_key_gen_spec", specs[1].description) Assert.assertEquals(listOf(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1), specs[1].encryptionPaddings) } - - @Test - @Config(sdk = [Build.VERSION_CODES.LOLLIPOP_MR1]) // API 22, before M - fun testGetPrioritizedKeyGenParameterSpecs_LegacyAPI() { - // Test on pre-M API where only legacy spec should be available - // Should include modern spec without wrap key but not the one with wrap key (requires API 28) - Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITH_PURPOSE_WRAP_KEY)) - .thenReturn(true) - Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_NEW_KEY_GEN_SPEC_FOR_WRAP_WITHOUT_PURPOSE_WRAP_KEY)) - .thenReturn(true) - Mockito.`when`(mockFlightsProvider!!.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) - .thenReturn(true) - // Re-create the factory with the updated flags - cryptoParameterSpecFactory = CryptoParameterSpecFactory( - mockContext!!, TEST_KEY_ALIAS, - mockFlightsProvider!! - ) - - // Get the prioritized specs - val specs = cryptoParameterSpecFactory!!.getPrioritizedKeyGenParameterSpecs() - - // Verify we have only the legacy spec regardless of flags - Assert.assertEquals(1, specs.size.toLong()) - Assert.assertEquals("legacy_key_gen_spec", specs[0].description) - } - + companion object { private const val TEST_KEY_ALIAS = "test_key_alias" } From fb8e00062d3f1f4a4c7a540122b6fc2d98efd53e Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 4 Sep 2025 16:58:51 -0700 Subject: [PATCH 45/51] refactor: Update cipher transformation assignment in WrappedSecretKey creation --- ...oreBackedSecretKeyProviderRolloutTest.java | 26 +++++++++---------- .../crypto/KeyStoreBackedSecretKeyProvider.kt | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java index d35c2141ff..5930f38dd2 100644 --- a/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java +++ b/common/src/androidTest/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProviderRolloutTest.java @@ -82,14 +82,14 @@ public void setUp() throws Exception { *

* Phase 1 represents the baseline configuration with both new format flights disabled: * - ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING = false (uses PKCS1 padding) - * - ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT = false (uses legacy format without metadata) + * - WRAPPED_SECRET_KEY_SERIALIZER_VERSION = 0 (uses legacy format without metadata) *

* This configuration is used to test scenarios where applications are running * with the original crypto implementation before any flight rollouts. * */ private void enablePhase1Flights() { - configureFlights(false, false); + configureFlights(false, 0); } /** @@ -98,7 +98,7 @@ private void enablePhase1Flights() { * Phase 2 represents the intermediate configuration where only the new wrapped * secret key format flight is enabled: * - ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING = false (still uses PKCS1 padding) - * - ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT = true (uses new format with metadata) + * - WRAPPED_SECRET_KEY_SERIALIZER_VERSION = 1 (uses new format with metadata) *

* This configuration is used to test scenarios where the new wrapped secret key * format has been rolled out but OAEP padding has not yet been enabled. @@ -107,7 +107,7 @@ private void enablePhase1Flights() { * */ private void enablePhase2Flights() { - configureFlights(false, true); + configureFlights(false, 1); } /** @@ -115,7 +115,7 @@ private void enablePhase2Flights() { *

* Phase 3 represents the final configuration with both flights enabled: * - ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING = true (uses OAEP with SHA and MGF1 padding) - * - ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT = true (uses new format with metadata) + * - WRAPPED_SECRET_KEY_SERIALIZER_VERSION = 1 (uses new format with metadata) *

* This configuration is used to test scenarios where all crypto enhancements * have been fully rolled out. It represents the target state for maximum @@ -123,7 +123,7 @@ private void enablePhase2Flights() { * */ private void enablePhase3Flights() { - configureFlights(true, true); + configureFlights(true, 1); } /** @@ -131,13 +131,13 @@ private void enablePhase3Flights() { * This method reduces code duplication across the enablePhase methods. * * @param enableOAEPWithSHAAndMGF1Padding whether to enable OAEP with SHA and MGF1 padding - * @param enableNewWrappedSecretKeyFormat whether to enable new wrapped secret key format + * @param wrappedSecretKeySerializerVersion the version of the wrapped secret key serializer (0=legacy, 1=JSON format) */ private void configureFlights(final boolean enableOAEPWithSHAAndMGF1Padding, - final boolean enableNewWrappedSecretKeyFormat) { + final int wrappedSecretKeySerializerVersion) { final IFlightsProvider mockFlightsProvider = createFlightsProvider( enableOAEPWithSHAAndMGF1Padding, - enableNewWrappedSecretKeyFormat); + wrappedSecretKeySerializerVersion); final IFlightsManager flightsManager = createFlightsManager(mockFlightsProvider); CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(flightsManager); } @@ -146,11 +146,11 @@ private void configureFlights(final boolean enableOAEPWithSHAAndMGF1Padding, * Creates a mock flights provider with the specified flight configurations. * * @param enableOAEPWithSHAAndMGF1Padding whether to enable OAEP with SHA and MGF1 padding - * @param enableNewWrappedSecretKeyFormat whether to enable new wrapped secret key format + * @param wrappedSecretKeySerializerVersion the version of the wrapped secret key serializer (0=legacy, 1=JSON format) * @return configured mock IFlightsProvider */ private IFlightsProvider createFlightsProvider(final boolean enableOAEPWithSHAAndMGF1Padding, - final boolean enableNewWrappedSecretKeyFormat) { + final int wrappedSecretKeySerializerVersion) { final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class); // These flights are always enabled for key generation spec improvements @@ -162,8 +162,8 @@ private IFlightsProvider createFlightsProvider(final boolean enableOAEPWithSHAAn // Configure the phase-specific flights Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_OAEP_WITH_SHA_AND_MGF1_PADDING)) .thenReturn(enableOAEPWithSHAAndMGF1Padding); - Mockito.when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_NEW_WRAPPED_SECRET_KEY_FORMAT)) - .thenReturn(enableNewWrappedSecretKeyFormat); + Mockito.when(mockFlightsProvider.getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION)) + .thenReturn(wrappedSecretKeySerializerVersion); return mockFlightsProvider; } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index eaef286b24..bb7905bd68 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -278,7 +278,7 @@ class KeyStoreBackedSecretKeyProvider( WrappedSecretKey( wrappedKeyData = wrappedKey, algorithm = secretKey.algorithm, - cipherTransformation = cipherTransformation + cipherTransformation = cipherParamsSpec.transformation ) } } catch (exception: Exception) { From a9319fdf6aef310fcbf66d7b75f2a6de0bf64927 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 16 Sep 2025 18:29:31 -0700 Subject: [PATCH 46/51] refactor: Update wrapped secret key serialization to use IDs and improve metadata handling --- .../IWrappedSecretKeySerializer.kt | 33 +-- .../wrappedsecretkey/WrappedSecretKey.kt | 18 +- .../WrappedSecretKeyBinaryStreamSerializer.kt | 95 +++++++++ .../WrappedSecretKeyJsonObjectSerializer.kt | 171 ---------------- .../WrappedSecretKeyLegacySerializer.kt | 64 +++--- .../WrappedSecretKeyMetadata.kt | 48 +++++ .../WrappedSecretKeySerializerManager.kt | 106 ++++------ .../WrappedSecretKeySerializerWithMetadata.kt | 188 ++++++++++++++++++ .../common/crypto/WrappedSecretKeyTest.kt | 6 +- 9 files changed, 440 insertions(+), 289 deletions(-) create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt delete mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt create mode 100644 common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt index c98b0ff7a2..a7326854e8 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/IWrappedSecretKeySerializer.kt @@ -23,33 +23,40 @@ package com.microsoft.identity.common.crypto.wrappedsecretkey /** - * Interface for serializing and deserializing WrappedSecretKey objects. + * Interface for serializing and deserializing [WrappedSecretKey] objects. * - * Implementations can define different serialization formats and versions. + * Each implementation handles a specific format and provides bidirectional + * conversion between [WrappedSecretKey] objects and their byte array representations. + * + * @see WrappedSecretKey + * @see WrappedSecretKeySerializerManager */ interface IWrappedSecretKeySerializer { /** - * Serialize the given WrappedSecretKey into a byte array. + * Serializes a [WrappedSecretKey] into its byte array representation. * - * @param wrappedSecretKey The WrappedSecretKey to serialize. - * @return The serialized byte array representation of the WrappedSecretKey. + * @param wrappedSecretKey The wrapped secret key to serialize + * @return The serialized byte array representation + * @throws IllegalArgumentException if the input is invalid */ fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray /** - * Deserialize the given byte array into a WrappedSecretKey object. + * Deserializes a byte array into a [WrappedSecretKey] object. * - * @param data The byte array to deserialize. - * @return The deserialized WrappedSecretKey object. - * @throws Exception if deserialization fails. + * @param wrappedSecretKeyByteArray The serialized byte array to deserialize + * @return The reconstructed [WrappedSecretKey] object + * @throws Exception if deserialization fails due to invalid format or corruption */ - fun deserialize(data: ByteArray): WrappedSecretKey + fun deserialize(wrappedSecretKeyByteArray: ByteArray): WrappedSecretKey /** - * Get the version of the serialization format used by this serializer. + * The unique identifier for this serialization format. + * + * Used by [WrappedSecretKeySerializerManager] for format detection and serializer selection. * - * @return The version number as an integer. + * @return The unique serializer format identifier (0-255) */ - fun getVersion(): Int + val id: Int } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt index 1aa9097592..949f2d7b24 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKey.kt @@ -32,9 +32,9 @@ import com.microsoft.identity.common.java.flighting.CommonFlightsManager * Supports multiple serialization formats controlled by flight configuration for backward compatibility. * * **Formats:** - * - Version 0: Legacy (raw key data only) - * - Version 1: JSON metadata format - * - Future versions: Extensible via flight config + * - ID 0: Legacy (raw wrappedSecretKey data only) + * - ID 1: Binary stream with metadata (UTF strings + int) + * - Future IDs: Extensible via flight config * * @property wrappedKeyData The encrypted secret key bytes * @property algorithm The key algorithm (e.g., "AES") @@ -71,13 +71,13 @@ data class WrappedSecretKey( /** * Serializes the key using the format specified by flight configuration. * - * @return Serialized byte array + * @return Serialized byte array using the configured serializer ID */ fun serialize(): ByteArray { - val serializerVersion = CommonFlightsManager.getFlightsProvider() + val serializerId = CommonFlightsManager.getFlightsProvider() .getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) return WrappedSecretKeySerializerManager - .getSerializer(serializerVersion) + .getSerializer(serializerId) .serialize(this) } @@ -87,12 +87,12 @@ data class WrappedSecretKey( * * @param data The serialized byte data * @return Reconstructed [WrappedSecretKey] - * @throws IllegalArgumentException if format is unsupported + * @throws IllegalArgumentException if serializer ID is unsupported */ fun deserialize(data: ByteArray): WrappedSecretKey { - val serializerVersion = WrappedSecretKeySerializerManager.getVersion(data) + val serializerId = WrappedSecretKeySerializerManager.identifySerializer(data) return WrappedSecretKeySerializerManager - .getSerializer(serializerVersion) + .getSerializer(serializerId) .deserialize(data) } } diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt new file mode 100644 index 0000000000..75b16dc1c1 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream + +/** + * Binary stream-based serializer for [WrappedSecretKey] with metadata support. + * + * Uses DataInputStream/DataOutputStream for compact binary metadata encoding. + * More efficient than JSON but less human-readable. + * + * **Format:** ID 2, Header + binary metadata + raw key data + * **Metadata:** UTF strings for algorithm/transformation, int for key length + * + * @see IWrappedSecretKeySerializer + */ +class WrappedSecretKeyBinaryStreamSerializer: WrappedSecretKeySerializerWithMetadata() { + + companion object { + /** Unique identifier for the binary stream serialization format */ + const val ID = 1 + } + + /** + * The unique identifier for this serialization format. + */ + override val id = ID + + /** + * Serializes wrapped secret key metadata to binary format. + * + * @param wrappedSecretKey The wrapped secret key containing metadata to serialize + * @return Binary encoded metadata as byte array + */ + override fun serializeMetadata(wrappedSecretKey: WrappedSecretKey): ByteArray { + return ByteArrayOutputStream().use { baos -> + DataOutputStream(baos).use { dos -> + // Write algorithm (writeUTF includes length prefix) + dos.writeUTF(wrappedSecretKey.algorithm) + // Write cipher transformation (writeUTF includes length prefix) + dos.writeUTF(wrappedSecretKey.cipherTransformation) + // Write key data length + dos.writeInt(wrappedSecretKey.wrappedKeyData.size) + } + baos.toByteArray() + } + } + + /** + * Deserializes binary metadata to create a [WrappedSecretKeyMetadata] object. + * + * @param metadataByteArray Binary encoded metadata bytes + * @return [WrappedSecretKeyMetadata] object with extracted information + * @throws java.io.IOException if binary format is invalid or corrupted + */ + override fun deserializeMetadata(metadataByteArray: ByteArray): WrappedSecretKeyMetadata { + return ByteArrayInputStream(metadataByteArray).use { bais -> + DataInputStream(bais).use { dis -> + val algorithm = dis.readUTF() + val cipherTransformation = dis.readUTF() + val keyLength = dis.readInt() + + WrappedSecretKeyMetadata( + algorithm = algorithm, + cipherTransformation = cipherTransformation, + keyLength = keyLength + ) + } + } + } +} \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt deleted file mode 100644 index 7a271fc7cd..0000000000 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyJsonObjectSerializer.kt +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// All rights reserved. -// -// This code is licensed under the MIT License. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files(the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions : -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -package com.microsoft.identity.common.crypto.wrappedsecretkey - -import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerManager.HEADER_ID_FIELD_SIZE_BYTES -import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerManager.METADATA_LENGTH_FIELD_SIZE_BYTES -import com.microsoft.identity.common.java.exception.ClientException -import com.microsoft.identity.common.logging.Logger -import org.json.JSONObject -import java.nio.ByteBuffer - -/** - * JSON-based serializer for [WrappedSecretKey] that stores metadata in JSON format. - * - * This serializer implements version 1 of the wrapped secret key format, storing metadata - * as JSON alongside the raw wrapped key data. This format provides better structure and - * extensibility compared to the legacy format while maintaining human-readable metadata. - * - * **Format structure:** - * ``` - * [Header ID: 4 bytes][Metadata Length: 4 bytes][JSON Metadata][Raw Key Data] - * ``` - * - * **JSON Metadata fields:** - * - `algorithm`: The key algorithm (e.g., "AES") - * - `cipherTransformation`: The cipher transformation used (e.g., "RSA/ECB/PKCS1Padding") - * - `version`: Format version (1 for this serializer) - * - `keyDataLength`: Length of the raw key data for validation - * - * **Format characteristics:** - * - Version: 1 - * - Header identifier: 0x00FF3C01 - * - Metadata encoding: UTF-8 JSON - * - Supports backward compatibility validation - * - Human-readable metadata for debugging - * - * This serializer bridges the gap between the legacy format (version 0) and more - * advanced binary formats, providing structured metadata while maintaining readability. - * - * @see IWrappedSecretKeySerializer - * @see WrappedSecretKey - * @see WrappedSecretKeyLegacySerializer - */ -class WrappedSecretKeyJsonObjectSerializer: IWrappedSecretKeySerializer { - - companion object { - private const val TAG = "WrappedSecretKeyJsonObjectSerializer" - - /** Version identifier for the JSON-based format */ - const val VERSION = 1 - - /** Header identifier used to recognize this format */ - private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3C01 - } - - /** - * Serializes a [WrappedSecretKey] to the JSON metadata format. - * - * Creates a structured binary format that includes a header identifier, metadata length, - * JSON-encoded metadata, and the raw wrapped key data. The metadata includes algorithm, - * cipher transformation, version, and key data length for validation purposes. - * - * @param wrappedSecretKey The wrapped secret key to serialize - * @return Serialized byte array in the JSON metadata format - */ - override fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray { - // New format: Store metadata header + raw key data - val metadata = JSONObject().apply { - put("algorithm", wrappedSecretKey.algorithm) - put("cipherTransformation", wrappedSecretKey.cipherTransformation) - put("version", VERSION) - put("keyDataLength", wrappedSecretKey.wrappedKeyData.size) - } - - val metadataBytes = metadata.toString().toByteArray(Charsets.UTF_8) - - // Use ByteBuffer for cleaner header writing - val bufferSize = HEADER_ID_FIELD_SIZE_BYTES + METADATA_LENGTH_FIELD_SIZE_BYTES + - metadataBytes.size + wrappedSecretKey.wrappedKeyData.size - return ByteBuffer.allocate(bufferSize) - .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Write header length (4 bytes, big-endian) - .putInt(metadataBytes.size) // Write metadata length (4 bytes, big-endian) - .put(metadataBytes) // Write metadata - .put(wrappedSecretKey.wrappedKeyData) // Write raw key data - .array() - } - - /** - * Deserializes byte data to a [WrappedSecretKey] from the JSON metadata format. - * - * Parses the structured binary format to extract the JSON metadata and raw key data. - * The method validates the metadata length and key data length for integrity checking. - * - * **Data format expected:** - * 1. Header identifier (4 bytes) - already validated by caller - * 2. Metadata length (4 bytes) - indicates JSON metadata size - * 3. JSON metadata (variable) - UTF-8 encoded JSON with key information - * 4. Raw key data (remaining bytes) - the actual wrapped key bytes - * - * @param data The serialized binary data containing header, metadata, and key data - * @return [WrappedSecretKey] instance reconstructed from the serialized data - * @throws ClientException if the data format is invalid, JSON parsing fails, or validation errors occur - */ - override fun deserialize(data: ByteArray): WrappedSecretKey { - val methodTag = "$TAG:loadFromNewFormat" - Logger.info(methodTag, "Loading key using JSON metadata format") - val buffer = ByteBuffer.wrap(data) - - // Skip header identifier (already validated in isNewFormat) - buffer.getInt() - - // Read metadata length - val metadataLength = buffer.getInt() - - // Extract and parse JSON metadata - val metadataBytes = ByteArray(metadataLength) - buffer.get(metadataBytes) - - val jsonString = String(metadataBytes, Charsets.UTF_8) - val json = JSONObject(jsonString) - val algorithm = json.getString("algorithm") - val cipherTransformation = json.getString("cipherTransformation") - val keyDataLength = json.getInt("keyDataLength") - - // Validate key data length - if (keyDataLength != buffer.remaining()) { - Logger.warn( - methodTag, - "Key data length mismatch. Expected: $keyDataLength, Actual: ${buffer.remaining()}" - ) - } - - val keyBytes = ByteArray(buffer.remaining()) - buffer.get(keyBytes) - - Logger.verbose( - methodTag, - "Successfully loaded key with algorithm: $algorithm, transformation: $cipherTransformation" - ) - return WrappedSecretKey(keyBytes, algorithm, cipherTransformation) - } - - /** - * Returns the version identifier for this serializer. - * - * @return Version 1, indicating the JSON metadata format - */ - override fun getVersion(): Int { - return VERSION - } -} diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt index bb67caf841..7456d83709 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyLegacySerializer.kt @@ -24,47 +24,50 @@ package com.microsoft.identity.common.crypto.wrappedsecretkey /** - * Legacy serializer for [WrappedSecretKey] that maintains backward compatibility with the original format. + * Legacy serializer for [WrappedSecretKey] that maintains backward compatibility. * - * This serializer handles the legacy format where only the raw wrapped key data is stored - * without any additional metadata such as algorithm or cipher transformation information. - * When deserializing legacy data, it applies default values for missing metadata to ensure - * proper key reconstruction. + * Handles the original format with raw key data only, applying default metadata + * during deserialization for proper key reconstruction. * - * **Format characteristics:** - * - Version: 0 (legacy) - * - Data: Raw wrapped key bytes only - * - No metadata header - * - Default algorithm: AES - * - Default cipher transformation: RSA/ECB/PKCS1Padding + * **Format:** + * - **ID**: 0 (legacy) + * - **Structure**: Raw wrapped key bytes only + * - **Defaults**: AES algorithm, RSA/ECB/PKCS1Padding transformation * - * This serializer is essential for maintaining compatibility when migrating from older - * key storage formats to newer metadata-aware formats. + * **Use cases:** + * - Deserializing pre-metadata format keys + * - Backward compatibility with existing storage + * - Migration to newer metadata-aware formats * * @see IWrappedSecretKeySerializer - * @see WrappedSecretKey */ class WrappedSecretKeyLegacySerializer : IWrappedSecretKeySerializer { companion object { - /** Version identifier for the legacy format */ - const val VERSION = 0 - /** Default algorithm used when no metadata is available */ private const val DEFAULT_ALGORITHM = "AES" /** Default cipher transformation used when no metadata is available */ private const val DEFAULT_CIPHER_TRANSFORMATION = "RSA/ECB/PKCS1Padding" + + /** Unique identifier for the legacy serialization format */ + const val ID = 0 } /** * Serializes a [WrappedSecretKey] to the legacy format. * * In the legacy format, only the raw wrapped key data is stored without any metadata. - * This maintains compatibility with older storage implementations. + * This maintains compatibility with older storage implementations that expect + * direct key data without headers or metadata sections. + * + * **Output format:** + * ``` + * [Raw Key Data] + * ``` * * @param wrappedSecretKey The wrapped secret key to serialize - * @return Raw wrapped key data as byte array + * @return Raw wrapped key data as byte array (no headers or metadata) */ override fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray { return wrappedSecretKey.wrappedKeyData @@ -75,26 +78,31 @@ class WrappedSecretKeyLegacySerializer : IWrappedSecretKeySerializer { * * Since the legacy format doesn't include metadata, this method applies default * values for algorithm and cipher transformation to reconstruct a complete - * [WrappedSecretKey] instance. + * [WrappedSecretKey] instance. The default values are chosen to maintain + * compatibility with the most common cryptographic configurations. * - * @param data Raw wrapped key data from legacy format + * **Default metadata applied:** + * - Algorithm: "AES" + * - Cipher transformation: "RSA/ECB/PKCS1Padding" + * + * @param wrappedSecretKeyByteArray The byte array containing the raw wrapped key data * @return [WrappedSecretKey] instance with legacy data and default metadata + * @throws IllegalArgumentException if data is null or empty */ - override fun deserialize(data: ByteArray): WrappedSecretKey { + override fun deserialize(wrappedSecretKeyByteArray: ByteArray): WrappedSecretKey { // Legacy format does not include metadata, use defaults return WrappedSecretKey( - wrappedKeyData = data, + wrappedKeyData = wrappedSecretKeyByteArray, algorithm = DEFAULT_ALGORITHM, cipherTransformation = DEFAULT_CIPHER_TRANSFORMATION ) } /** - * Returns the version identifier for this serializer. + * The unique identifier for this serialization format. * - * @return Version 0, indicating the legacy format + * Returns 0 to indicate the legacy format, which is used by the + * [WrappedSecretKeySerializerManager] for format detection and serializer selection. */ - override fun getVersion(): Int { - return VERSION - } + override val id = ID } \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt new file mode 100644 index 0000000000..7a09b3c395 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +/** + * Metadata container for wrapped secret key cryptographic information. + * + * This data class encapsulates the essential cryptographic metadata associated with a wrapped secret key, + * providing type-safe access to algorithm specifications and validation information. + * + * **Usage:** + * Used internally by serializers to store and retrieve metadata during serialization/deserialization + * processes, ensuring that cryptographic context is preserved across storage operations. + * + * @property algorithm The cryptographic algorithm used for the secret key (e.g., "AES", "DES") + * @property cipherTransformation The complete cipher transformation specification including algorithm, + * mode, and padding (e.g., "RSA/ECB/PKCS1Padding", "AES/CBC/PKCS5Padding") + * @property keyLength The length of the wrapped key data in bytes, used for validation during deserialization + * + * @see WrappedSecretKey + * @see IWrappedSecretKeySerializer + * @see WrappedSecretKeySerializerWithMetadata + */ +data class WrappedSecretKeyMetadata( + val algorithm: String, + val cipherTransformation: String, + val keyLength: Int +) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt index 9561505da7..cf393defd5 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt @@ -23,111 +23,87 @@ package com.microsoft.identity.common.crypto.wrappedsecretkey import com.microsoft.identity.common.logging.Logger -import java.nio.ByteBuffer /** - * Manager for handling different versions of [WrappedSecretKey] serialization formats. + * Manager for handling different IDs of [WrappedSecretKey] serialization formats. * * This object provides centralized management for serializing and deserializing wrapped secret keys - * across different format versions. It handles version detection, serializer selection, and + * across different format IDs. It handles ID detection, serializer selection, and * maintains backward compatibility between legacy and modern formats. * * **Supported formats:** - * - **Version 0 (Legacy)**: Raw key data only, no metadata or header - * - **Version 1 (JSON)**: Header + JSON metadata + raw key data - * - **Future versions**: Extensible design for new serialization formats + * - **ID 0 (Legacy)**: wrappedSecretKey data only, no metadata or header + * - **ID 1 (Binary Stream)**: metadata with structured header + * - **Future IDs**: Extensible design for new serialization formats * * **Header structure for new formats:** * ``` - * [Header ID: 4 bytes][Metadata Length: 4 bytes][Metadata][Raw Key Data] + * [Header ID: 4 bytes][Serializer ID: 4 bytes][Metadata Length: 4 bytes][Metadata][wrappedSecretKey] * ``` * - * **Version encoding:** - * The header identifier uses the first 3 bytes (0x00FF3C) as a format identifier, - * with the last byte encoding the version number (0x00-0xFF), allowing for 256 different versions. + * **ID encoding:** + * The header identifier uses the first 3 bytes (0x00FF3CAB) as a format identifier, + * with the serializer ID stored separately, allowing for 256 different format IDs. * - * **Version detection algorithm:** + * **ID detection algorithm:** * 1. Check if data has minimum header size * 2. Extract header identifier (first 4 bytes) - * 3. Mask and compare first 3 bytes against known format identifier - * 4. Extract version from last byte, or assume legacy format (version 0) + * 3. Compare first 3 bytes against known format identifier + * 4. Extract serializer ID from header, or assume legacy format (ID 0) * * @see IWrappedSecretKeySerializer * @see WrappedSecretKey * @see WrappedSecretKeyLegacySerializer - * @see WrappedSecretKeyJsonObjectSerializer + * @see WrappedSecretKeyBinaryStreamSerializer */ object WrappedSecretKeySerializerManager { private const val TAG = "WrappedSecretKeySerializerManager" - /** Size in bytes for the header identifier field */ - const val HEADER_ID_FIELD_SIZE_BYTES = Int.SIZE_BYTES - - /** Size in bytes for the metadata length field */ - const val METADATA_LENGTH_FIELD_SIZE_BYTES = Int.SIZE_BYTES - - /** Base header identifier for new format (first 3 bytes), with version byte as 0x00 */ - private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3C00 - - /** Mask to isolate the first 3 bytes of the header for format identification */ - private const val NEW_FORMAT_HEADER_MASK = 0xFFFFFF00 // Mask to compare only first 3 bytes - - /** Mask to extract the version byte (last byte) from the header identifier */ - private const val VERSION_BYTE_MASK = 0x000000FF /** - * Extracts the serializer version from the header of wrapped key data. + * Extracts the serializer ID from the header of wrapped key data. * - * The version is stored in the last byte of the 4-byte header identifier. + * The ID is stored in the header identifier of metadata format data. * This allows for backward compatibility when introducing new serialization formats. * - * **Version detection logic:** - * 1. If data is too small for a header, assume legacy format (version 0) + * **ID detection logic:** + * 1. If data is too small for a header, assume legacy format (ID 0) * 2. Extract the 4-byte header identifier * 3. Use bit masking to compare only the first 3 bytes against the known format identifier - * 4. If matched, extract the version from the last byte; otherwise, return legacy version + * 4. If matched, extract the ID from the header; otherwise, return legacy ID * - * @param rawData The raw bytes read from file containing the wrapped key data - * @return The version number (0-255) if the data uses the new format, 0 if legacy format or invalid data + * @param wrappedSecretKeyByteArray The byte array to inspect + * @return The serializer ID (0-255) if the data uses the new format, 0 if legacy format or invalid data */ - fun getVersion(rawData: ByteArray): Int { - val methodTag = "$TAG:getVersion" - if ((rawData.size < HEADER_ID_FIELD_SIZE_BYTES + METADATA_LENGTH_FIELD_SIZE_BYTES)) { - Logger.warn(methodTag, "Data too small to contain header, assuming legacy format") - return WrappedSecretKeyLegacySerializer.VERSION - } - val buffer = ByteBuffer.wrap(rawData) - val headerValue = buffer.getInt() - // Mask out the version byte (last byte) and compare only the first 3 bytes - return if ((headerValue and NEW_FORMAT_HEADER_MASK.toInt()) == NEW_FORMAT_HEADER_IDENTIFIER) { - headerValue and VERSION_BYTE_MASK // Return the version byte - } else { - Logger.warn(methodTag, "Data does not match known format identifier, assuming legacy format") - WrappedSecretKeyLegacySerializer.VERSION // Legacy format - } + fun identifySerializer(wrappedSecretKeyByteArray: ByteArray): Int { + val methodTag = "$TAG:identifySerializer" + val serializerId = WrappedSecretKeySerializerWithMetadata + .getSerializerIdFromByteArray(wrappedSecretKeyByteArray) + Logger.info(methodTag, "Detected serializer ID: $serializerId") + return serializerId ?: WrappedSecretKeyLegacySerializer.ID // Legacy format } /** - * Returns the appropriate serializer instance for the specified version. + * Returns the appropriate serializer instance for the specified ID. * - * Creates and returns a serializer that can handle the specified format version. + * Creates and returns a serializer that can handle the specified format ID. * This factory method ensures that the correct serialization strategy is used - * for each supported format version. + * for each supported format ID. * - * **Supported versions:** - * - Version 0: [WrappedSecretKeyLegacySerializer] for legacy format - * - Version 1: [WrappedSecretKeyJsonObjectSerializer] for JSON metadata format + * **Supported IDs:** + * - ID 0: [WrappedSecretKeyLegacySerializer] for legacy format + * - ID 1: [WrappedSecretKeyBinaryStreamSerializer] for binary stream format * - * @param version The serialization format version number - * @return An [IWrappedSecretKeySerializer] instance capable of handling the specified version - * @throws IllegalArgumentException if the version is not supported + * @param serializerId The serialization format ID number + * @return An [IWrappedSecretKeySerializer] instance capable of handling the specified ID + * @throws IllegalArgumentException if the ID is not supported */ - fun getSerializer(version: Int): IWrappedSecretKeySerializer { + fun getSerializer(serializerId: Int): IWrappedSecretKeySerializer { val methodTag = "$TAG:getSerializer" - Logger.info(methodTag, "Getting serializer for version: $version") - return when (version) { - WrappedSecretKeyJsonObjectSerializer.VERSION -> WrappedSecretKeyJsonObjectSerializer() - WrappedSecretKeyLegacySerializer.VERSION -> WrappedSecretKeyLegacySerializer() - else -> throw IllegalArgumentException("Unsupported WrappedSecretKey version: $version") + Logger.info(methodTag, "Getting serializer for ID: $serializerId") + return when (serializerId) { + WrappedSecretKeyBinaryStreamSerializer.ID -> WrappedSecretKeyBinaryStreamSerializer() + WrappedSecretKeyLegacySerializer.ID -> WrappedSecretKeyLegacySerializer() + else -> throw IllegalArgumentException("Unsupported serializer ID: $serializerId") } } } \ No newline at end of file diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt new file mode 100644 index 0000000000..9172bc2d07 --- /dev/null +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +import com.microsoft.identity.common.logging.Logger +import java.nio.ByteBuffer + +/** + * Abstract base class for wrapped secret key serializers that include metadata in their format. + * + * This class provides the common infrastructure for serialization formats that store metadata + * alongside the wrapped key data. It handles the standard header structure, format detection, + * and provides a template method pattern for concrete serializers to implement their specific + * metadata encoding strategies. + * + * **Binary format structure:** + * ``` + * [Header ID: 4 bytes][Serializer ID: 4 bytes][Metadata Length: 4 bytes][Metadata][Raw Key Data] + * ``` + * + * **Header components:** + * - **Header ID**: Fixed identifier (0x00FF3CAB) for format recognition + * - **Serializer ID**: Unique identifier for the specific serializer implementation + * - **Metadata Length**: Size of the metadata section in bytes + * - **Metadata**: Format-specific metadata (JSON, binary, etc.) + * - **Raw Key Data**: The actual wrapped secret key bytes + * + * @see IWrappedSecretKeySerializer + * @see WrappedSecretKeySerializerManager + */ + +abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializer { + + companion object { + const val TAG = "WrappedSecretKeySerializerWithMetadata" + + /** Size in bytes for the header identifier field */ + const val HEADER_ID_FIELD_SIZE_BYTES = Int.SIZE_BYTES + + /** Size in bytes for the metadata ID field */ + const val METADATA_ID_SIZE_BYTES = Int.SIZE_BYTES + + /** Size in bytes for the metadata length field */ + const val METADATA_LENGTH_FIELD_SIZE_BYTES = Int.SIZE_BYTES + + /** Base header identifier for new format */ + const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3CAB + + /** + * Determines if the byte array was serialized with metadata format. + * + * Checks if the data contains the expected header structure and identifier + * to distinguish between legacy format (raw key data only) and metadata + * format (header + metadata + key data). + * + * @param wrappedSecretKeyByteArray The serialized data to examine + * @return true if the data uses metadata format, false if legacy format + */ + private fun isSerializedWithMetadata(wrappedSecretKeyByteArray: ByteArray): Boolean { + val methodTag = "$TAG:isNewFormat" + if ((wrappedSecretKeyByteArray.size < + HEADER_ID_FIELD_SIZE_BYTES + + METADATA_ID_SIZE_BYTES + + METADATA_LENGTH_FIELD_SIZE_BYTES)) { + Logger.warn(methodTag, "Data too small to contain header, assuming legacy format") + return false + } + val headerId = ByteBuffer.wrap(wrappedSecretKeyByteArray, 0, HEADER_ID_FIELD_SIZE_BYTES).int + return headerId == NEW_FORMAT_HEADER_IDENTIFIER + } + + /** + * Extracts the serializer ID from metadata format byte array. + * + * Reads the serializer ID from the header section of metadata format data. + * This ID is used by the [WrappedSecretKeySerializerManager] to select + * the appropriate deserializer for the data. + * + * @param wrappedSecretKeyByteArray The serialized data to examine + * @return The serializer ID if metadata format is detected, null if legacy format + */ + fun getSerializerIdFromByteArray(wrappedSecretKeyByteArray: ByteArray): Int? { + if (!isSerializedWithMetadata(wrappedSecretKeyByteArray)) { + return null + } + return ByteBuffer.wrap(wrappedSecretKeyByteArray, HEADER_ID_FIELD_SIZE_BYTES, HEADER_ID_FIELD_SIZE_BYTES).int + } + } + + /** + * Serializes the metadata portion of a wrapped secret key. + * + * Concrete implementations must provide format-specific metadata serialization. + * The metadata should include all information necessary to reconstruct the + * cryptographic context (algorithm, cipher transformation, etc.). + * + * @param wrappedSecretKey The wrapped secret key containing metadata to serialize + * @return The serialized metadata as a byte array + */ + abstract fun serializeMetadata(wrappedSecretKey: WrappedSecretKey): ByteArray + + /** + * Deserializes metadata from its byte array representation. + * + * Concrete implementations must provide format-specific metadata deserialization + * that corresponds to their [serializeMetadata] implementation. + * + * @param metadataByteArray The serialized metadata bytes + * @return The deserialized metadata object + * @throws Exception if metadata format is invalid or corrupted + */ + abstract fun deserializeMetadata(metadataByteArray: ByteArray): WrappedSecretKeyMetadata + + override fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray { + + val metadataBytes = serializeMetadata(wrappedSecretKey) + + // Use ByteBuffer for cleaner header writing + val bufferSize = HEADER_ID_FIELD_SIZE_BYTES + + METADATA_LENGTH_FIELD_SIZE_BYTES + + METADATA_ID_SIZE_BYTES + + metadataBytes.size + + wrappedSecretKey.wrappedKeyData.size + return ByteBuffer.allocate(bufferSize) + .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Write header length (4 bytes, big-endian) + .putInt(id) + .putInt(metadataBytes.size) // Write metadata length (4 bytes, big-endian) + .put(metadataBytes) // Write metadata + .put(wrappedSecretKey.wrappedKeyData) // Write raw key data + .array() + } + + override fun deserialize(wrappedSecretKeyByteArray: ByteArray): WrappedSecretKey { + val methodTag = "$TAG:loadFromNewFormat" + Logger.info(methodTag, "Loading key using JSON metadata format") + val buffer = ByteBuffer.wrap(wrappedSecretKeyByteArray) + + // Skip header identifier (already validated in isNewFormat) + buffer.getInt() + + // Read metadata length + val metadataLength = buffer.getInt() + + // Extract and parse JSON metadata + val metadataBytes = ByteArray(metadataLength) + buffer.get(metadataBytes) + + val metadata = deserializeMetadata(metadataBytes) + + + // Validate key data length + if (metadata.keyLength != buffer.remaining()) { + Logger.warn( + methodTag, + "Key data length mismatch. Expected: $metadata.keyLength, Actual: ${buffer.remaining()}" + ) + } + + val keyBytes = ByteArray(buffer.remaining()) + buffer.get(keyBytes) + + Logger.verbose( + methodTag, + "Successfully loaded key with algorithm: ${metadata.algorithm}, transformation: ${metadata.cipherTransformation}" + ) + return WrappedSecretKey(keyBytes, metadata.algorithm, metadata.cipherTransformation) + } +} \ No newline at end of file diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index 1b0c9d7e4c..3f10f5149e 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -338,18 +338,18 @@ class WrappedSecretKeyTest { // Test new format version detection every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 1 val newFormatData = wrappedKey.serialize() - val detectedNewVersion = WrappedSecretKeySerializerManager.getVersion(newFormatData) + val detectedNewVersion = WrappedSecretKeySerializerManager.identifySerializer(newFormatData) assertEquals("Should detect version 1 for new format", 1, detectedNewVersion) // Test legacy format version detection every { CommonFlightsManager.getFlightsProvider().getIntValue(CommonFlight.WRAPPED_SECRET_KEY_SERIALIZER_VERSION) } returns 0 val legacyFormatData = wrappedKey.serialize() - val detectedLegacyVersion = WrappedSecretKeySerializerManager.getVersion(legacyFormatData) + val detectedLegacyVersion = WrappedSecretKeySerializerManager.identifySerializer(legacyFormatData) assertEquals("Should detect version 0 for legacy format", 0, detectedLegacyVersion) // Test raw binary data (should be detected as legacy) val rawData = "random-binary-data".toByteArray() - val detectedRawVersion = WrappedSecretKeySerializerManager.getVersion(rawData) + val detectedRawVersion = WrappedSecretKeySerializerManager.identifySerializer(rawData) assertEquals("Should detect version 0 for raw data", 0, detectedRawVersion) } From 2caeae11207493a29f610b50e5cf00dde4ccdba5 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 16 Sep 2025 19:40:38 -0700 Subject: [PATCH 47/51] repair merge error --- .../internal/util/AndroidKeyStoreUtil.java | 82 ++++++++++++++++--- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java index d1ddddca66..868e8f058b 100644 --- a/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java +++ b/common/src/main/java/com/microsoft/identity/common/internal/util/AndroidKeyStoreUtil.java @@ -483,6 +483,68 @@ public static synchronized SecretKey unwrap(final byte[] wrappedKeyBlob, throw clientException; } + /** + * Populate attributes from an InvalidKeyException, attempting to extract details from a nested + * KeyStoreException if available (API Level 33+). + */ + private static AttributesBuilder createAttributesBuilderFromInvalidKeyException(final InvalidKeyException exception) { + String ksMessage; + final String errorType; + final String ksNumericErrorCode; + + // Check API Level before attempting to extract KeyStoreException details + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + final android.security.KeyStoreException keyStoreException = findKeyStoreException(exception); + if (keyStoreException != null) { + ksMessage = keyStoreException.getMessage(); + if (ksMessage == null) { + ksMessage = "Keystore exception found, no error message"; + } + errorType = "KeyStoreException"; + ksNumericErrorCode = String.valueOf(keyStoreException.getNumericErrorCode()); + } else { + ksMessage = "No keystore exception found"; + errorType = "InvalidKeyException"; + ksNumericErrorCode = ""; + } + } else { + ksMessage = "API Level below 33, keystore exception not available"; + errorType = "InvalidKeyException"; + ksNumericErrorCode = ""; + } + + return Attributes.builder() + .put(AttributeName.error_type.name(), errorType) + .put(AttributeName.keystore_exception_stack_trace.name(), ThrowableUtil.getStackTraceAsString(exception)) + .put(AttributeName.keystore_exception_message.name(), ksMessage) + .put(AttributeName.keystore_numeric_error_code.name(), ksNumericErrorCode); + } + + /** + * Searches the causal chain of the given throwable for an instance of + * {@link android.security.KeyStoreException}. + * + * @param throwable The throwable to search. + * @return The found KeyStoreException, or null if none was found or the API level is below 33. + */ + private static @Nullable android.security.KeyStoreException findKeyStoreException(@NonNull Throwable throwable) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Check up to a max depth to avoid infinite loops in case of circular references + int count = 0; + while (throwable != null && count < KEYSTORE_EXCEPTION_CAUSE_CHAIN_MAX_DEPTH) { + if (throwable instanceof android.security.KeyStoreException) { + return (android.security.KeyStoreException) throwable; + } + throwable = throwable.getCause(); + count++; + } + + return null; + } else { + return null; + } + } + /** * Returns encryption paddings supported by a KeyStore key pair. *

@@ -498,19 +560,15 @@ public static synchronized List getKeyPairEncryptionPaddings(@NonNull fi try { final PrivateKey privateKey = keyPair.getPrivate(); final KeyFactory keyFactory = KeyFactory.getInstance(privateKey.getAlgorithm(), ANDROID_KEY_STORE_TYPE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - final KeyInfo keyInfo = keyFactory.getKeySpec(privateKey, KeyInfo.class); - final List encryptionPaddings = new ArrayList<>(); - // keyInfo.getEncryptionPaddings() returns a list of encryption paddings supported by the key. - // We remove the "Padding" suffix from each padding name to match the expected format. - for (final String padding : keyInfo.getEncryptionPaddings()) { - encryptionPaddings.add(padding.replace("Padding", "")); - } - Logger.info(methodTag, "Supported encryption paddings: " + encryptionPaddings); - return encryptionPaddings; - } else { - Logger.warn(methodTag, "KeyInfo not available on API < 23"); + final KeyInfo keyInfo = keyFactory.getKeySpec(privateKey, KeyInfo.class); + final List encryptionPaddings = new ArrayList<>(); + // keyInfo.getEncryptionPaddings() returns a list of encryption paddings supported by the key. + // We remove the "Padding" suffix from each padding name to match the expected format. + for (final String padding : keyInfo.getEncryptionPaddings()) { + encryptionPaddings.add(padding.replace("Padding", "")); } + Logger.info(methodTag, "Supported encryption paddings: " + encryptionPaddings); + return encryptionPaddings; } catch (final Exception e) { Logger.warn(methodTag, "Failed to retrieve key padding information" + ": " + e.getMessage()); } From e0105eab69784a0e58a7f272a0e55ea98f3270f7 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 16 Sep 2025 20:05:42 -0700 Subject: [PATCH 48/51] refactor: Update metadata handling in WrappedSecretKeySerializer and add unit tests for serializer manager --- .../WrappedSecretKeySerializerWithMetadata.kt | 12 +- .../WrappedSecretKeySerializerManagerTest.kt | 256 ++++++++++++++++++ 2 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 common/src/test/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManagerTest.kt diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt index 9172bc2d07..f31af147e1 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt @@ -63,7 +63,7 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ /** Size in bytes for the metadata length field */ const val METADATA_LENGTH_FIELD_SIZE_BYTES = Int.SIZE_BYTES - /** Base header identifier for new format */ + /** Header identifier for new format */ const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3CAB /** @@ -152,27 +152,29 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ override fun deserialize(wrappedSecretKeyByteArray: ByteArray): WrappedSecretKey { val methodTag = "$TAG:loadFromNewFormat" - Logger.info(methodTag, "Loading key using JSON metadata format") + Logger.info(methodTag, "Loading key using metadata format") val buffer = ByteBuffer.wrap(wrappedSecretKeyByteArray) // Skip header identifier (already validated in isNewFormat) buffer.getInt() + // Skip serializer ID (we already know which serializer we are) + buffer.getInt() + // Read metadata length val metadataLength = buffer.getInt() - // Extract and parse JSON metadata + // Extract and parse metadata val metadataBytes = ByteArray(metadataLength) buffer.get(metadataBytes) val metadata = deserializeMetadata(metadataBytes) - // Validate key data length if (metadata.keyLength != buffer.remaining()) { Logger.warn( methodTag, - "Key data length mismatch. Expected: $metadata.keyLength, Actual: ${buffer.remaining()}" + "Key data length mismatch. Expected: ${metadata.keyLength}, Actual: ${buffer.remaining()}" ) } diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManagerTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManagerTest.kt new file mode 100644 index 0000000000..c0384b98b5 --- /dev/null +++ b/common/src/test/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManagerTest.kt @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.crypto.wrappedsecretkey + +import org.junit.Assert.* +import org.junit.Test + +/** + * Unit tests for [WrappedSecretKeySerializerManager]. + * + * Tests the manager's ability to identify serializer IDs from byte data + * and return appropriate serializer instances without using mocks. + */ +class WrappedSecretKeySerializerManagerTest { + + companion object { + private const val TEST_ALGORITHM = "AES" + private const val TEST_CIPHER_TRANSFORMATION = "RSA/ECB/PKCS1Padding" + private val TEST_KEY_DATA = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + } + + private fun createTestWrappedSecretKey(): WrappedSecretKey { + return WrappedSecretKey( + wrappedKeyData = TEST_KEY_DATA, + algorithm = TEST_ALGORITHM, + cipherTransformation = TEST_CIPHER_TRANSFORMATION + ) + } + + // ===== Tests for identifySerializer ===== + + @Test + fun `identifySerializer returns legacy ID for legacy format data`() { + val legacyData = TEST_KEY_DATA // Raw key data without headers + + val serializerId = WrappedSecretKeySerializerManager.identifySerializer(legacyData) + + assertEquals(WrappedSecretKeyLegacySerializer.ID, serializerId) + } + + @Test + fun `identifySerializer returns legacy ID for empty data`() { + val emptyData = byteArrayOf() + + val serializerId = WrappedSecretKeySerializerManager.identifySerializer(emptyData) + + assertEquals(WrappedSecretKeyLegacySerializer.ID, serializerId) + } + + @Test + fun `identifySerializer returns legacy ID for data too small for header`() { + val smallData = byteArrayOf(1, 2, 3) // Less than minimum header size + + val serializerId = WrappedSecretKeySerializerManager.identifySerializer(smallData) + + assertEquals(WrappedSecretKeyLegacySerializer.ID, serializerId) + } + + @Test + fun `identifySerializer correctly identifies binary stream serializer format`() { + val testKey = createTestWrappedSecretKey() + val streamSerializer = WrappedSecretKeyBinaryStreamSerializer() + val serializedData = streamSerializer.serialize(testKey) + + val serializerId = WrappedSecretKeySerializerManager.identifySerializer(serializedData) + + assertEquals(WrappedSecretKeyBinaryStreamSerializer.ID, serializerId) + } + + @Test + fun `identifySerializer handles corrupted header gracefully`() { + val corruptedHeader = byteArrayOf( + 0x00, 0xFF.toByte(), 0x3C, 0x00, // Invalid header (missing 0xAB) + 0x00, 0x00, 0x00, 0x01, // Serializer ID + 0x00, 0x00, 0x00, 0x10, // Metadata length + // ... rest would be metadata and key data + ) + + val serializerId = WrappedSecretKeySerializerManager.identifySerializer(corruptedHeader) + + assertEquals(WrappedSecretKeyLegacySerializer.ID, serializerId) + } + + // ===== Tests for getSerializer ===== + + @Test + fun `getSerializer returns legacy serializer for ID 0`() { + val serializer = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyLegacySerializer.ID) + + assertTrue(serializer is WrappedSecretKeyLegacySerializer) + assertEquals(WrappedSecretKeyLegacySerializer.ID, serializer.id) + } + + @Test + fun `getSerializer returns binary stream serializer for ID 1`() { + val serializer = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyBinaryStreamSerializer.ID) + + assertTrue(serializer is WrappedSecretKeyBinaryStreamSerializer) + assertEquals(WrappedSecretKeyBinaryStreamSerializer.ID, serializer.id) + } + + @Test + fun `getSerializer throws IllegalArgumentException for unsupported ID`() { + val unsupportedId = 999 + + val exception = assertThrows(IllegalArgumentException::class.java) { + WrappedSecretKeySerializerManager.getSerializer(unsupportedId) + } + + assertTrue(exception.message!!.contains("Unsupported serializer ID: $unsupportedId")) + } + + @Test + fun `getSerializer throws IllegalArgumentException for negative ID`() { + val negativeId = -1 + + val exception = assertThrows(IllegalArgumentException::class.java) { + WrappedSecretKeySerializerManager.getSerializer(negativeId) + } + + assertTrue(exception.message!!.contains("Unsupported serializer ID: $negativeId")) + } + + // ===== Integration Tests ===== + + @Test + fun `round trip serialization with legacy format works correctly`() { + val originalKey = createTestWrappedSecretKey() + val legacySerializer = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyLegacySerializer.ID) + + // Serialize + val serializedData = legacySerializer.serialize(originalKey) + + // Identify format + val detectedId = WrappedSecretKeySerializerManager.identifySerializer(serializedData) + assertEquals(WrappedSecretKeyLegacySerializer.ID, detectedId) + + // Deserialize + val retrievedSerializer = WrappedSecretKeySerializerManager.getSerializer(detectedId) + val deserializedKey = retrievedSerializer.deserialize(serializedData) + + // Verify (note: legacy format uses default metadata) + assertArrayEquals(originalKey.wrappedKeyData, deserializedKey.wrappedKeyData) + assertEquals("AES", deserializedKey.algorithm) // Default from legacy serializer + assertEquals("RSA/ECB/PKCS1Padding", deserializedKey.cipherTransformation) // Default from legacy serializer + } + + @Test + fun `round trip serialization with binary stream format preserves metadata`() { + val originalKey = createTestWrappedSecretKey() + val streamSerializer = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyBinaryStreamSerializer.ID) + + // Serialize + val serializedData = streamSerializer.serialize(originalKey) + + // Identify format + val detectedId = WrappedSecretKeySerializerManager.identifySerializer(serializedData) + assertEquals(WrappedSecretKeyBinaryStreamSerializer.ID, detectedId) + + // Deserialize + val retrievedSerializer = WrappedSecretKeySerializerManager.getSerializer(detectedId) + val deserializedKey = retrievedSerializer.deserialize(serializedData) + + // Verify complete preservation + assertArrayEquals(originalKey.wrappedKeyData, deserializedKey.wrappedKeyData) + assertEquals(originalKey.algorithm, deserializedKey.algorithm) + assertEquals(originalKey.cipherTransformation, deserializedKey.cipherTransformation) + } + + @Test + fun `manager correctly handles different key sizes`() { + val largeKeyData = ByteArray(1024) { it.toByte() } + val largeKey = WrappedSecretKey(largeKeyData, TEST_ALGORITHM, TEST_CIPHER_TRANSFORMATION) + + // Test with binary stream format + val streamSerializer = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyBinaryStreamSerializer.ID) + val serializedData = streamSerializer.serialize(largeKey) + + val detectedId = WrappedSecretKeySerializerManager.identifySerializer(serializedData) + assertEquals(WrappedSecretKeyBinaryStreamSerializer.ID, detectedId) + + val retrievedSerializer = WrappedSecretKeySerializerManager.getSerializer(detectedId) + val deserializedKey = retrievedSerializer.deserialize(serializedData) + + assertArrayEquals(largeKey.wrappedKeyData, deserializedKey.wrappedKeyData) + } + + @Test + fun `manager handles special characters in metadata correctly`() { + val specialKey = WrappedSecretKey( + wrappedKeyData = TEST_KEY_DATA, + algorithm = "AES-256-GCM", + cipherTransformation = "AES/GCM/NoPadding" + ) + + // Test with binary stream format + val serializer = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyBinaryStreamSerializer.ID) + val serializedData = serializer.serialize(specialKey) + + val detectedId = WrappedSecretKeySerializerManager.identifySerializer(serializedData) + assertEquals(WrappedSecretKeyBinaryStreamSerializer.ID, detectedId) + + val retrievedSerializer = WrappedSecretKeySerializerManager.getSerializer(detectedId) + val deserializedKey = retrievedSerializer.deserialize(serializedData) + + assertEquals(specialKey.algorithm, deserializedKey.algorithm) + assertEquals(specialKey.cipherTransformation, deserializedKey.cipherTransformation) + } + + // ===== Edge Case Tests ===== + + @Test + fun `identifySerializer handles minimum valid header size`() { + // Create minimum valid header (12 bytes: header + serializer ID + metadata length) + val minimalHeader = byteArrayOf( + 0x00, 0xFF.toByte(), 0x3C, 0xAB.toByte(), // Valid header + 0x00, 0x00, 0x00, 0x01, // Serializer ID = 1 + 0x00, 0x00, 0x00, 0x00, // Metadata length = 0 + // No actual metadata or key data + ) + + val serializerId = WrappedSecretKeySerializerManager.identifySerializer(minimalHeader) + + assertEquals(1, serializerId) // Should detect ID 1 from header + } + + @Test + fun `getSerializer creates new instances each time`() { + val serializer1 = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyBinaryStreamSerializer.ID) + val serializer2 = WrappedSecretKeySerializerManager.getSerializer(WrappedSecretKeyBinaryStreamSerializer.ID) + + assertNotSame(serializer1, serializer2) // Different instances + assertEquals(serializer1.id, serializer2.id) // Same ID + assertEquals(serializer1::class, serializer2::class) // Same type + } +} From 9bdd48b257479c6b636968e86f857cee18029552 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Tue, 16 Sep 2025 22:10:28 -0700 Subject: [PATCH 49/51] upadte tests and doc --- .../WrappedSecretKeyBinaryStreamSerializer.kt | 3 +- .../WrappedSecretKeySerializerManager.kt | 37 ++++---- .../WrappedSecretKeySerializerWithMetadata.kt | 29 +++---- .../common/crypto/WrappedSecretKeyTest.kt | 84 ++++++++++++------- 4 files changed, 84 insertions(+), 69 deletions(-) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt index 75b16dc1c1..bf80622593 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt @@ -33,7 +33,6 @@ import java.io.DataOutputStream * Uses DataInputStream/DataOutputStream for compact binary metadata encoding. * More efficient than JSON but less human-readable. * - * **Format:** ID 2, Header + binary metadata + raw key data * **Metadata:** UTF strings for algorithm/transformation, int for key length * * @see IWrappedSecretKeySerializer @@ -56,6 +55,7 @@ class WrappedSecretKeyBinaryStreamSerializer: WrappedSecretKeySerializerWithMeta * @param wrappedSecretKey The wrapped secret key containing metadata to serialize * @return Binary encoded metadata as byte array */ + @Throws(java.io.IOException::class) override fun serializeMetadata(wrappedSecretKey: WrappedSecretKey): ByteArray { return ByteArrayOutputStream().use { baos -> DataOutputStream(baos).use { dos -> @@ -77,6 +77,7 @@ class WrappedSecretKeyBinaryStreamSerializer: WrappedSecretKeySerializerWithMeta * @return [WrappedSecretKeyMetadata] object with extracted information * @throws java.io.IOException if binary format is invalid or corrupted */ + @Throws(java.io.IOException::class) override fun deserializeMetadata(metadataByteArray: ByteArray): WrappedSecretKeyMetadata { return ByteArrayInputStream(metadataByteArray).use { bais -> DataInputStream(bais).use { dis -> diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt index cf393defd5..218bc82655 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt @@ -38,18 +38,7 @@ import com.microsoft.identity.common.logging.Logger * * **Header structure for new formats:** * ``` - * [Header ID: 4 bytes][Serializer ID: 4 bytes][Metadata Length: 4 bytes][Metadata][wrappedSecretKey] - * ``` - * - * **ID encoding:** - * The header identifier uses the first 3 bytes (0x00FF3CAB) as a format identifier, - * with the serializer ID stored separately, allowing for 256 different format IDs. - * - * **ID detection algorithm:** - * 1. Check if data has minimum header size - * 2. Extract header identifier (first 4 bytes) - * 3. Compare first 3 bytes against known format identifier - * 4. Extract serializer ID from header, or assume legacy format (ID 0) + * [Magic Bytes: 4 bytes][Serializer ID: 4 bytes][Metadata Length: 4 bytes][Metadata][wrappedSecretKey] * * @see IWrappedSecretKeySerializer * @see WrappedSecretKey @@ -60,19 +49,23 @@ object WrappedSecretKeySerializerManager { private const val TAG = "WrappedSecretKeySerializerManager" /** - * Extracts the serializer ID from the header of wrapped key data. + * Extracts the serializer ID from wrapped secret key data. + * + * Analyzes the byte array to determine which serialization format was used. + * This enables automatic format detection and backward compatibility across different versions. * - * The ID is stored in the header identifier of metadata format data. - * This allows for backward compatibility when introducing new serialization formats. + * **Detection logic:** + * 1. Check if data contains metadata format magic bytes (0x00FF3CAB) + * 2. If magic bytes found, extract serializer ID from header + * 3. If no magic bytes or insufficient data, assume legacy format (ID 0) * - * **ID detection logic:** - * 1. If data is too small for a header, assume legacy format (ID 0) - * 2. Extract the 4-byte header identifier - * 3. Use bit masking to compare only the first 3 bytes against the known format identifier - * 4. If matched, extract the ID from the header; otherwise, return legacy ID + * **Supported formats:** + * - **ID 0**: Legacy format (raw wrapped key data only) + * - **ID 1**: Binary stream with metadata header * - * @param wrappedSecretKeyByteArray The byte array to inspect - * @return The serializer ID (0-255) if the data uses the new format, 0 if legacy format or invalid data + * @param wrappedSecretKeyByteArray The serialized wrapped secret key data to analyze + * @return The detected serializer ID (0 for legacy, 1+ for metadata formats) + * @see WrappedSecretKeySerializerWithMetadata.getSerializerIdFromByteArray */ fun identifySerializer(wrappedSecretKeyByteArray: ByteArray): Int { val methodTag = "$TAG:identifySerializer" diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt index f31af147e1..e4e7abcaf4 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt @@ -35,13 +35,14 @@ import java.nio.ByteBuffer * * **Binary format structure:** * ``` - * [Header ID: 4 bytes][Serializer ID: 4 bytes][Metadata Length: 4 bytes][Metadata][Raw Key Data] + * [Magic Bytes: 4 bytes][Serializer ID: 4 bytes][Metadata Length: 4 bytes][Metadata][Raw Key Data] * ``` * * **Header components:** - * - **Header ID**: Fixed identifier (0x00FF3CAB) for format recognition + * - **Magic Bytes**: Fixed identifier (0x00FF3CAB) for format recognition * - **Serializer ID**: Unique identifier for the specific serializer implementation * - **Metadata Length**: Size of the metadata section in bytes + * ** Body components: * - **Metadata**: Format-specific metadata (JSON, binary, etc.) * - **Raw Key Data**: The actual wrapped secret key bytes * @@ -54,8 +55,8 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ companion object { const val TAG = "WrappedSecretKeySerializerWithMetadata" - /** Size in bytes for the header identifier field */ - const val HEADER_ID_FIELD_SIZE_BYTES = Int.SIZE_BYTES + /** Size in bytes for the magic bytes identifier field */ + const val MAGIC_BYTES_SIZE_BYTES = Int.SIZE_BYTES /** Size in bytes for the metadata ID field */ const val METADATA_ID_SIZE_BYTES = Int.SIZE_BYTES @@ -63,15 +64,15 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ /** Size in bytes for the metadata length field */ const val METADATA_LENGTH_FIELD_SIZE_BYTES = Int.SIZE_BYTES - /** Header identifier for new format */ - const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3CAB + /** Magic bytes identifier for metadata format */ + const val METADATA_FORMAT_MAGIC_BYTES = 0x00FF3CAB /** * Determines if the byte array was serialized with metadata format. * * Checks if the data contains the expected header structure and identifier * to distinguish between legacy format (raw key data only) and metadata - * format (header + metadata + key data). + * format (magic bytes + serializer id + metadata lenght). * * @param wrappedSecretKeyByteArray The serialized data to examine * @return true if the data uses metadata format, false if legacy format @@ -79,14 +80,14 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ private fun isSerializedWithMetadata(wrappedSecretKeyByteArray: ByteArray): Boolean { val methodTag = "$TAG:isNewFormat" if ((wrappedSecretKeyByteArray.size < - HEADER_ID_FIELD_SIZE_BYTES + + MAGIC_BYTES_SIZE_BYTES + METADATA_ID_SIZE_BYTES + METADATA_LENGTH_FIELD_SIZE_BYTES)) { Logger.warn(methodTag, "Data too small to contain header, assuming legacy format") return false } - val headerId = ByteBuffer.wrap(wrappedSecretKeyByteArray, 0, HEADER_ID_FIELD_SIZE_BYTES).int - return headerId == NEW_FORMAT_HEADER_IDENTIFIER + val magicBytes = ByteBuffer.wrap(wrappedSecretKeyByteArray, 0, MAGIC_BYTES_SIZE_BYTES).int + return magicBytes == METADATA_FORMAT_MAGIC_BYTES } /** @@ -103,7 +104,7 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ if (!isSerializedWithMetadata(wrappedSecretKeyByteArray)) { return null } - return ByteBuffer.wrap(wrappedSecretKeyByteArray, HEADER_ID_FIELD_SIZE_BYTES, HEADER_ID_FIELD_SIZE_BYTES).int + return ByteBuffer.wrap(wrappedSecretKeyByteArray, MAGIC_BYTES_SIZE_BYTES, MAGIC_BYTES_SIZE_BYTES).int } } @@ -136,13 +137,13 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ val metadataBytes = serializeMetadata(wrappedSecretKey) // Use ByteBuffer for cleaner header writing - val bufferSize = HEADER_ID_FIELD_SIZE_BYTES + + val bufferSize = MAGIC_BYTES_SIZE_BYTES + METADATA_LENGTH_FIELD_SIZE_BYTES + METADATA_ID_SIZE_BYTES + metadataBytes.size + wrappedSecretKey.wrappedKeyData.size return ByteBuffer.allocate(bufferSize) - .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Write header length (4 bytes, big-endian) + .putInt(METADATA_FORMAT_MAGIC_BYTES) // Write header magic bytes (4 bytes, big-endian) .putInt(id) .putInt(metadataBytes.size) // Write metadata length (4 bytes, big-endian) .put(metadataBytes) // Write metadata @@ -155,7 +156,7 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ Logger.info(methodTag, "Loading key using metadata format") val buffer = ByteBuffer.wrap(wrappedSecretKeyByteArray) - // Skip header identifier (already validated in isNewFormat) + // Skip magic bytes identifier buffer.getInt() // Skip serializer ID (we already know which serializer we are) diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index 3f10f5149e..12ffdaefb1 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -24,17 +24,16 @@ package com.microsoft.identity.common.crypto import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKey import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerManager +import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerWithMetadata.Companion.METADATA_FORMAT_MAGIC_BYTES import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkAll -import org.json.JSONObject import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test -import java.nio.BufferUnderflowException import java.nio.ByteBuffer class WrappedSecretKeyTest { @@ -45,8 +44,7 @@ class WrappedSecretKeyTest { companion object { // New format constants matching the implementation - private const val NEW_FORMAT_HEADER_IDENTIFIER = 0x00FF3C01 - private const val FORMAT_VERSION_1 = 1 + private const val NEW_SERIALIZER_ID_1 = 1 } @Before @@ -130,28 +128,37 @@ class WrappedSecretKeyTest { // Read and verify the binary structure val buffer = ByteBuffer.wrap(serializedData) - // Verify header identifier (4 bytes) - val headerIdentifier = buffer.getInt() - assertEquals("Header identifier should match expected value", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) + // Verify magic bytes identifier (4 bytes) + val magicBytes = buffer.getInt() + assertEquals("Magic bytes should match expected value", METADATA_FORMAT_MAGIC_BYTES, magicBytes) + + // Verify serializer ID (4 bytes) + val serializerId = buffer.getInt() + assertEquals("Should use binary stream serializer ID", NEW_SERIALIZER_ID_1, serializerId) // Verify metadata length (4 bytes) val metadataLength = buffer.getInt() assertTrue("Metadata length should be reasonable", metadataLength > 0 && metadataLength < serializedData.size) - // Read and verify metadata + // Read and verify binary metadata using DataInputStream val metadataBytes = ByteArray(metadataLength) buffer.get(metadataBytes) - val metadata = JSONObject(String(metadataBytes, Charsets.UTF_8)) - assertEquals(testAlgorithm, metadata.getString("algorithm")) - assertEquals(testCipherTransformation, metadata.getString("cipherTransformation")) - assertEquals(FORMAT_VERSION_1, metadata.getInt("version")) - assertEquals(testKeyBytes.size, metadata.getInt("keyDataLength")) + val dataInputStream = java.io.DataInputStream(java.io.ByteArrayInputStream(metadataBytes)) + + // Read binary metadata fields (algorithm, cipherTransformation, keyDataLength) + val algorithmFromMetadata = dataInputStream.readUTF() + val cipherTransformationFromMetadata = dataInputStream.readUTF() + val keyDataLengthFromMetadata = dataInputStream.readInt() + + assertEquals("Algorithm should match", testAlgorithm, algorithmFromMetadata) + assertEquals("Cipher transformation should match", testCipherTransformation, cipherTransformationFromMetadata) + assertEquals("Key data length should match", testKeyBytes.size, keyDataLengthFromMetadata) // Verify remaining data is the key val remainingKeyData = ByteArray(buffer.remaining()) buffer.get(remainingKeyData) - assertArrayEquals(testKeyBytes, remainingKeyData) + assertArrayEquals("Remaining data should be the original key", testKeyBytes, remainingKeyData) } @Test @@ -245,7 +252,7 @@ class WrappedSecretKeyTest { // Validate key is serialized with new format val buffer = ByteBuffer.wrap(serializedData) val headerIdentifier = buffer.getInt() - assertEquals("Key should be serialized with new format", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) + assertEquals("Key should be serialized with new format", METADATA_FORMAT_MAGIC_BYTES, headerIdentifier) // Phase 2: Deserialize (automatic format detection should work regardless of flight value) val deserializedKey = WrappedSecretKey.deserialize(serializedData) @@ -290,25 +297,38 @@ class WrappedSecretKeyTest { // Validate the structure manually val buffer = ByteBuffer.wrap(serializedData) - // Check header identifier - val headerIdentifier = buffer.getInt() - assertEquals("New format should have correct header identifier", NEW_FORMAT_HEADER_IDENTIFIER, headerIdentifier) + // Check magic bytes identifier + val magicBytes = buffer.getInt() + assertEquals("New format should have correct magic bytes identifier", METADATA_FORMAT_MAGIC_BYTES, magicBytes) + + // Check serializer ID + val serializerId = buffer.getInt() + assertEquals("Should use binary stream serializer ID", NEW_SERIALIZER_ID_1, serializerId) - // Check metadata + // Check metadata length val metadataLength = buffer.getInt() + assertTrue("Metadata length should be positive", metadataLength > 0) + assertTrue("Metadata length should be reasonable", metadataLength < serializedData.size) + + // Read and validate binary metadata using DataInputStream val metadataBytes = ByteArray(metadataLength) buffer.get(metadataBytes) - val metadata = JSONObject(String(metadataBytes, Charsets.UTF_8)) - assertTrue("Metadata should contain algorithm", metadata.has("algorithm")) - assertTrue("Metadata should contain cipherTransformation", metadata.has("cipherTransformation")) - assertTrue("Metadata should contain version", metadata.has("version")) - assertTrue("Metadata should contain keyDataLength", metadata.has("keyDataLength")) + val dataInputStream = java.io.DataInputStream(java.io.ByteArrayInputStream(metadataBytes)) + + // Read binary metadata fields (algorithm, cipherTransformation, keyDataLength) + val algorithmFromMetadata = dataInputStream.readUTF() + val cipherTransformationFromMetadata = dataInputStream.readUTF() + val keyDataLengthFromMetadata = dataInputStream.readInt() + + assertEquals("Algorithm should match", testAlgorithm, algorithmFromMetadata) + assertEquals("Cipher transformation should match", testCipherTransformation, cipherTransformationFromMetadata) + assertEquals("Key data length should match", testKeyBytes.size, keyDataLengthFromMetadata) - assertEquals("Algorithm should match", testAlgorithm, metadata.getString("algorithm")) - assertEquals("Cipher transformation should match", testCipherTransformation, metadata.getString("cipherTransformation")) - assertEquals("Version should be 1", FORMAT_VERSION_1, metadata.getInt("version")) - assertEquals("Key data length should match", testKeyBytes.size, metadata.getInt("keyDataLength")) + // Verify remaining data is the key + val remainingKeyData = ByteArray(buffer.remaining()) + buffer.get(remainingKeyData) + assertArrayEquals("Remaining data should be the original key", testKeyBytes, remainingKeyData) } /** @@ -356,11 +376,11 @@ class WrappedSecretKeyTest { /** * Test edge case where metadata has corrupted length in new format. */ - @Test(expected = NegativeArraySizeException::class) + @Test(expected = IllegalArgumentException::class) fun corruptedMetadataLengthThrowsException() { // Create new format with corrupted metadata length val corruptedData = ByteBuffer.allocate(16) - .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Correct header identifier + .putInt(METADATA_FORMAT_MAGIC_BYTES) // Correct header identifier .putInt(-1) // Invalid metadata length (negative) .put("test".toByteArray()) .array() @@ -372,11 +392,11 @@ class WrappedSecretKeyTest { /** * Test edge case where metadata length exceeds remaining data. */ - @Test(expected = BufferUnderflowException::class) + @Test(expected = IllegalArgumentException::class) fun metadataLengthLargerThanRemainingDataThrowsException() { // Create new format with metadata length larger than remaining data val corruptedData = ByteBuffer.allocate(16) - .putInt(NEW_FORMAT_HEADER_IDENTIFIER) // Correct header identifier + .putInt(METADATA_FORMAT_MAGIC_BYTES) // Correct header identifier .putInt(1000) // Metadata length larger than remaining data .put("test".toByteArray()) .array() From 2fdaa1226f331532a4559d758ccedbcba5e59ddb Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 25 Sep 2025 22:11:04 -0700 Subject: [PATCH 50/51] Refactor key serialization: Introduce AbstractWrappedSecretKeySerializer and update related classes for improved structure and metadata handling. --- .../microsoft/identity/common/crypto/KeyGenSpec.kt | 3 --- .../common/crypto/KeyStoreBackedSecretKeyProvider.kt | 1 - ...data.kt => AbstractWrappedSecretKeySerializer.kt} | 6 +++--- .../WrappedSecretKeyBinaryStreamSerializer.kt | 12 ++++++------ .../wrappedsecretkey/WrappedSecretKeyMetadata.kt | 2 +- .../WrappedSecretKeySerializerManager.kt | 4 ++-- .../identity/common/crypto/WrappedSecretKeyTest.kt | 2 +- .../identity/common/java/flighting/CommonFlight.java | 2 +- 8 files changed, 14 insertions(+), 18 deletions(-) rename common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/{WrappedSecretKeySerializerWithMetadata.kt => AbstractWrappedSecretKeySerializer.kt} (97%) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt index 7c122225e5..85821ab1fa 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyGenSpec.kt @@ -23,10 +23,8 @@ package com.microsoft.identity.common.crypto import android.content.Context -import android.os.Build import android.security.KeyPairGeneratorSpec import android.security.keystore.KeyGenParameterSpec -import androidx.annotation.RequiresApi import java.math.BigInteger import java.security.spec.AlgorithmParameterSpec import java.util.Calendar @@ -86,7 +84,6 @@ data class KeyGenSpec( return encryptionPaddings.toTypedArray() } - @RequiresApi(Build.VERSION_CODES.M) override val algorithmParameterSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(keyAlias, purposes) .setKeySize(keySize) diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt index bb7905bd68..f28df65760 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/KeyStoreBackedSecretKeyProvider.kt @@ -365,7 +365,6 @@ class KeyStoreBackedSecretKeyProvider( return compatibleSpecs } - /** * Generates a new RSA key pair using prioritized specifications with fallback support. * diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/AbstractWrappedSecretKeySerializer.kt similarity index 97% rename from common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt rename to common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/AbstractWrappedSecretKeySerializer.kt index e4e7abcaf4..73d9f58658 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerWithMetadata.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/AbstractWrappedSecretKeySerializer.kt @@ -50,10 +50,10 @@ import java.nio.ByteBuffer * @see WrappedSecretKeySerializerManager */ -abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializer { +abstract class AbstractWrappedSecretKeySerializer: IWrappedSecretKeySerializer { companion object { - const val TAG = "WrappedSecretKeySerializerWithMetadata" + const val TAG = "AbstractWrappedSecretKeySerializer" /** Size in bytes for the magic bytes identifier field */ const val MAGIC_BYTES_SIZE_BYTES = Int.SIZE_BYTES @@ -130,7 +130,7 @@ abstract class WrappedSecretKeySerializerWithMetadata: IWrappedSecretKeySerializ * @return The deserialized metadata object * @throws Exception if metadata format is invalid or corrupted */ - abstract fun deserializeMetadata(metadataByteArray: ByteArray): WrappedSecretKeyMetadata + protected abstract fun deserializeMetadata(metadataByteArray: ByteArray): WrappedSecretKeyMetadata override fun serialize(wrappedSecretKey: WrappedSecretKey): ByteArray { diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt index bf80622593..7e065d17a9 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyBinaryStreamSerializer.kt @@ -37,7 +37,7 @@ import java.io.DataOutputStream * * @see IWrappedSecretKeySerializer */ -class WrappedSecretKeyBinaryStreamSerializer: WrappedSecretKeySerializerWithMetadata() { +class WrappedSecretKeyBinaryStreamSerializer: AbstractWrappedSecretKeySerializer() { companion object { /** Unique identifier for the binary stream serialization format */ @@ -57,8 +57,8 @@ class WrappedSecretKeyBinaryStreamSerializer: WrappedSecretKeySerializerWithMeta */ @Throws(java.io.IOException::class) override fun serializeMetadata(wrappedSecretKey: WrappedSecretKey): ByteArray { - return ByteArrayOutputStream().use { baos -> - DataOutputStream(baos).use { dos -> + return ByteArrayOutputStream().use { bytesOutputStream -> + DataOutputStream(bytesOutputStream).use { dos -> // Write algorithm (writeUTF includes length prefix) dos.writeUTF(wrappedSecretKey.algorithm) // Write cipher transformation (writeUTF includes length prefix) @@ -66,7 +66,7 @@ class WrappedSecretKeyBinaryStreamSerializer: WrappedSecretKeySerializerWithMeta // Write key data length dos.writeInt(wrappedSecretKey.wrappedKeyData.size) } - baos.toByteArray() + bytesOutputStream.toByteArray() } } @@ -79,8 +79,8 @@ class WrappedSecretKeyBinaryStreamSerializer: WrappedSecretKeySerializerWithMeta */ @Throws(java.io.IOException::class) override fun deserializeMetadata(metadataByteArray: ByteArray): WrappedSecretKeyMetadata { - return ByteArrayInputStream(metadataByteArray).use { bais -> - DataInputStream(bais).use { dis -> + return ByteArrayInputStream(metadataByteArray).use { byteArrayInputStream -> + DataInputStream(byteArrayInputStream).use { dis -> val algorithm = dis.readUTF() val cipherTransformation = dis.readUTF() val keyLength = dis.readInt() diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt index 7a09b3c395..09739b91d6 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeyMetadata.kt @@ -39,7 +39,7 @@ package com.microsoft.identity.common.crypto.wrappedsecretkey * * @see WrappedSecretKey * @see IWrappedSecretKeySerializer - * @see WrappedSecretKeySerializerWithMetadata + * @see AbstractWrappedSecretKeySerializer */ data class WrappedSecretKeyMetadata( val algorithm: String, diff --git a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt index 218bc82655..9ae96aa5cd 100644 --- a/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt +++ b/common/src/main/java/com/microsoft/identity/common/crypto/wrappedsecretkey/WrappedSecretKeySerializerManager.kt @@ -65,11 +65,11 @@ object WrappedSecretKeySerializerManager { * * @param wrappedSecretKeyByteArray The serialized wrapped secret key data to analyze * @return The detected serializer ID (0 for legacy, 1+ for metadata formats) - * @see WrappedSecretKeySerializerWithMetadata.getSerializerIdFromByteArray + * @see AbstractWrappedSecretKeySerializer.getSerializerIdFromByteArray */ fun identifySerializer(wrappedSecretKeyByteArray: ByteArray): Int { val methodTag = "$TAG:identifySerializer" - val serializerId = WrappedSecretKeySerializerWithMetadata + val serializerId = AbstractWrappedSecretKeySerializer .getSerializerIdFromByteArray(wrappedSecretKeyByteArray) Logger.info(methodTag, "Detected serializer ID: $serializerId") return serializerId ?: WrappedSecretKeyLegacySerializer.ID // Legacy format diff --git a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt index 12ffdaefb1..c7fafb3e7a 100644 --- a/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt +++ b/common/src/test/java/com/microsoft/identity/common/crypto/WrappedSecretKeyTest.kt @@ -24,7 +24,7 @@ package com.microsoft.identity.common.crypto import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKey import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerManager -import com.microsoft.identity.common.crypto.wrappedsecretkey.WrappedSecretKeySerializerWithMetadata.Companion.METADATA_FORMAT_MAGIC_BYTES +import com.microsoft.identity.common.crypto.wrappedsecretkey.AbstractWrappedSecretKeySerializer.Companion.METADATA_FORMAT_MAGIC_BYTES import com.microsoft.identity.common.java.flighting.CommonFlight import com.microsoft.identity.common.java.flighting.CommonFlightsManager import io.mockk.every diff --git a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java index b43c96c1dd..de4e15841b 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java +++ b/common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java @@ -129,7 +129,7 @@ public enum CommonFlight implements IFlightConfig { /** * Flight to enable the new KEK algorithm for encryption/decryption of keys. */ - ENABLE_KEYSTORE_BACKED_SECRET_KEY_PROVIDER("EnableKeyStoreBackedSecretKeyProvider", false), + ENABLE_KEYSTORE_BACKED_SECRET_KEY_PROVIDER("EnableKeyStoreBackedSecretKeyProvider", true), /** * Flight to control the WrappedSecretKey serializer version From 76c0fa779bae44381ec3bba1955cb2a74ffd19e0 Mon Sep 17 00:00:00 2001 From: p3dr0rv Date: Thu, 25 Sep 2025 23:30:14 -0700 Subject: [PATCH 51/51] Update tests to use KeyStoreBackedSecretKeyProvider instead of AndroidWrappedKeyProvider --- .../crypto/AndroidAuthSdkStorageEncryptionManagerTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 fd29ed29c0..ab7399ef61 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 @@ -63,7 +63,7 @@ public void testGetEncryptionKey() { final AndroidAuthSdkStorageEncryptionManager manager = new AndroidAuthSdkStorageEncryptionManager(context); final ISecretKeyProvider provider = manager.getKeyProviderForEncryption(); - Assert.assertTrue(provider instanceof AndroidWrappedKeyProvider); + Assert.assertTrue(provider instanceof KeyStoreBackedSecretKeyProvider); Assert.assertNotEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(provider)); } @@ -87,7 +87,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey() { final List keyproviderList = manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); Assert.assertEquals(1, keyproviderList.size()); - Assert.assertTrue(keyproviderList.get(0) instanceof AndroidWrappedKeyProvider); + Assert.assertTrue(keyproviderList.get(0) instanceof KeyStoreBackedSecretKeyProvider); Assert.assertNotEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(keyproviderList.get(0))); } @@ -102,7 +102,7 @@ public void testGetDecryptionKey_ForDataEncryptedWithKeyStoreKey_PreDefinedKeyPr final List keyproviderList = manager.getKeyProviderForDecryption(TEXT_ENCRYPTED_BY_ANDROID_WRAPPED_KEY); Assert.assertEquals(1, keyproviderList.size()); - Assert.assertTrue(keyproviderList.get(0) instanceof AndroidWrappedKeyProvider); + Assert.assertTrue(keyproviderList.get(0) instanceof KeyStoreBackedSecretKeyProvider); Assert.assertNotEquals(KeyUtil.getKeyThumbPrint(secretKeyMock), KeyUtil.getKeyThumbPrint(keyproviderList.get(0))); }