Created
February 7, 2020 13:11
-
-
Save OleksandrKucherenko/34b5c72445bcf9d5519af6898afcb8fb to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.oblador.keychain; | |
import android.content.Context; | |
import android.content.SharedPreferences; | |
import android.content.pm.PackageManager; | |
import android.hardware.fingerprint.FingerprintManager; | |
import android.os.Build; | |
import androidx.annotation.NonNull; | |
import androidx.biometric.BiometricManager; | |
import androidx.test.core.app.ApplicationProvider; | |
import com.facebook.react.bridge.JavaOnlyMap; | |
import com.facebook.react.bridge.Promise; | |
import com.facebook.react.bridge.ReactApplicationContext; | |
import com.oblador.keychain.KeychainModule.AccessControl; | |
import com.oblador.keychain.KeychainModule.Errors; | |
import com.oblador.keychain.KeychainModule.KnownCiphers; | |
import com.oblador.keychain.KeychainModule.Maps; | |
import com.oblador.keychain.cipherStorage.CipherStorage; | |
import com.oblador.keychain.cipherStorage.CipherStorageBase; | |
import com.oblador.keychain.cipherStorage.CipherStorageFacebookConceal; | |
import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAesCbc; | |
import com.oblador.keychain.cipherStorage.CipherStorageKeystoreRsaEcb; | |
import com.oblador.keychain.exceptions.CryptoFailedException; | |
import com.oblador.keychain.exceptions.KeyStoreAccessException; | |
import org.junit.After; | |
import org.junit.Before; | |
import org.junit.ClassRule; | |
import org.junit.Rule; | |
import org.junit.Test; | |
import org.junit.rules.TestName; | |
import org.junit.rules.Timeout; | |
import org.junit.runner.RunWith; | |
import org.mockito.ArgumentCaptor; | |
import org.mockito.Mockito; | |
import org.mockito.junit.MockitoJUnit; | |
import org.mockito.junit.MockitoRule; | |
import org.mockito.junit.VerificationCollector; | |
import org.robolectric.RobolectricTestRunner; | |
import org.robolectric.annotation.Config; | |
import java.security.KeyStore; | |
import java.security.Security; | |
import javax.crypto.Cipher; | |
import static org.hamcrest.MatcherAssert.assertThat; | |
import static org.hamcrest.Matchers.instanceOf; | |
import static org.hamcrest.Matchers.is; | |
import static org.hamcrest.Matchers.notNullValue; | |
import static org.mockito.ArgumentMatchers.any; | |
import static org.mockito.ArgumentMatchers.eq; | |
import static org.mockito.ArgumentMatchers.isNull; | |
import static org.mockito.Mockito.mock; | |
import static org.mockito.Mockito.verify; | |
import static org.mockito.Mockito.when; | |
import static org.robolectric.Shadows.shadowOf; | |
@RunWith(RobolectricTestRunner.class) | |
public class KeychainModuleTests { | |
public static final byte[] BYTES_USERNAME = "username".getBytes(); | |
public static final byte[] BYTES_PASSWORD = "password".getBytes(); | |
/** | |
* Cancel test after 5 seconds. | |
*/ | |
@ClassRule | |
public static Timeout timeout = Timeout.seconds(10); | |
/** | |
* Get test method name. | |
*/ | |
@Rule | |
public TestName methodName = new TestName(); | |
/** | |
* Mock all the dependencies. | |
*/ | |
@Rule | |
public MockitoRule mockDependencies = MockitoJUnit.rule().silent(); | |
@Rule | |
public VerificationCollector collector = MockitoJUnit.collector(); | |
/** | |
* Security fake provider. | |
*/ | |
private FakeProvider provider = new FakeProvider(); | |
@Before | |
public void setUp() throws Exception { | |
provider.configuration.clear(); | |
Security.insertProviderAt(provider, 0); | |
} | |
@After | |
public void tearDown() throws Exception { | |
Security.removeProvider(FakeProvider.NAME); | |
} | |
@NonNull | |
private ReactApplicationContext getRNContext() { | |
return new ReactApplicationContext(ApplicationProvider.getApplicationContext()); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.LOLLIPOP) | |
public void testFingerprintNoHardware_api21() throws Exception { | |
// GIVEN: API21 android version | |
ReactApplicationContext context = getRNContext(); | |
KeychainModule module = new KeychainModule(context); | |
// WHEN: verify availability | |
final int result = BiometricManager.from(context).canAuthenticate(); | |
final boolean isFingerprintAvailable = module.isFingerprintAuthAvailable(); | |
// THEN: in api lower 23 - biometric is not available at all | |
assertThat(isFingerprintAvailable, is(false)); | |
assertThat(result, is(BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE)); | |
// fingerprint hardware not available, minimal API for fingerprint is api23, Android 6.0 | |
// https://developer.android.com/about/versions/marshmallow/android-6.0 | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.M) | |
public void testFingerprintAvailableButNotConfigured_api23() throws Exception { | |
// GIVEN: | |
// fingerprint api available but not configured properly | |
// API23 android version | |
ReactApplicationContext context = getRNContext(); | |
KeychainModule module = new KeychainModule(context); | |
// set that hardware is available | |
FingerprintManager fm = (FingerprintManager) context.getSystemService(Context.FINGERPRINT_SERVICE); | |
shadowOf(fm).setIsHardwareDetected(true); | |
// WHEN: check availability | |
final int result = BiometricManager.from(context).canAuthenticate(); | |
final boolean isFingerprintWorking = module.isFingerprintAuthAvailable(); | |
// THEN: another status from biometric api, fingerprint is still unavailable | |
assertThat(result, is(BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED)); | |
assertThat(isFingerprintWorking, is(false)); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.M) | |
public void testFingerprintConfigured_api23() throws Exception { | |
// GIVEN: | |
// API23 android version | |
// Fingerprints are configured | |
// fingerprint feature is ignored by android os | |
ReactApplicationContext context = getRNContext(); | |
// set that hardware is available | |
FingerprintManager fm = (FingerprintManager) context.getSystemService(Context.FINGERPRINT_SERVICE); | |
shadowOf(fm).setIsHardwareDetected(true); | |
shadowOf(fm).setDefaultFingerprints(5); // 5 fingerprints are available | |
// WHEN: check availability | |
final int result = BiometricManager.from(context).canAuthenticate(); | |
final KeychainModule module = new KeychainModule(context); | |
final boolean isFingerprintWorking = module.isFingerprintAuthAvailable(); | |
// THEN: biometric works | |
assertThat(result, is(BiometricManager.BIOMETRIC_SUCCESS)); | |
assertThat(isFingerprintWorking, is(true)); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.P) | |
public void testFingerprintConfigured_api28() throws Exception { | |
// GIVEN: | |
// API28 android version | |
// for api24+ system feature should be enabled | |
// fingerprints are configured | |
ReactApplicationContext context = getRNContext(); | |
shadowOf(context.getPackageManager()).setSystemFeature(PackageManager.FEATURE_FINGERPRINT, true); | |
// set that hardware is available | |
FingerprintManager fm = (FingerprintManager) context.getSystemService(Context.FINGERPRINT_SERVICE); | |
shadowOf(fm).setIsHardwareDetected(true); | |
shadowOf(fm).setDefaultFingerprints(5); // 5 fingerprints are available | |
// WHEN: verify availability | |
final int result = BiometricManager.from(context).canAuthenticate(); | |
final KeychainModule module = new KeychainModule(context); | |
final boolean isFingerprintWorking = module.isFingerprintAuthAvailable(); | |
// THEN: biometrics works | |
assertThat(result, is(BiometricManager.BIOMETRIC_SUCCESS)); | |
assertThat(isFingerprintWorking, is(true)); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.KITKAT) | |
public void testExtractFacebookConceal_NoHardware_api19() throws Exception { | |
// GIVEN: | |
// API19, minimal Android version | |
final ReactApplicationContext context = getRNContext(); | |
// WHEN: ask keychain for secured storage | |
final KeychainModule module = new KeychainModule(context); | |
final CipherStorage storage = module.getCipherStorageForCurrentAPILevel(); | |
// THEN: expected Facebook cipher storage, its the only one that supports API19 | |
assertThat(storage, notNullValue()); | |
assertThat(storage, instanceOf(CipherStorageFacebookConceal.class)); | |
assertThat(storage.isBiometrySupported(), is(false)); | |
assertThat(storage.securityLevel(), is(SecurityLevel.ANY)); | |
assertThat(storage.getMinSupportedApiLevel(), is(Build.VERSION_CODES.JELLY_BEAN)); | |
assertThat(storage.supportsSecureHardware(), is(false)); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.M) | |
public void testExtractAesCbc_NoFingerprintConfigured_api23() throws Exception { | |
// GIVEN: | |
// API23 android version | |
final ReactApplicationContext context = getRNContext(); | |
// WHEN: get the best secured storage | |
final KeychainModule module = new KeychainModule(context); | |
final CipherStorage storage = module.getCipherStorageForCurrentAPILevel(); | |
// THEN: | |
// expected AES cipher storage due no fingerprint available | |
// AES win and returned instead of facebook cipher | |
assertThat(storage, notNullValue()); | |
assertThat(storage, instanceOf(CipherStorageKeystoreAesCbc.class)); | |
assertThat(storage.isBiometrySupported(), is(false)); | |
assertThat(storage.securityLevel(), is(SecurityLevel.SECURE_HARDWARE)); | |
assertThat(storage.getMinSupportedApiLevel(), is(Build.VERSION_CODES.M)); | |
assertThat(storage.supportsSecureHardware(), is(true)); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.M) | |
public void testExtractRsaEcb_EnabledFingerprint_api23() throws Exception { | |
// GIVEN: | |
// API23 android version | |
// fingerprints configured | |
final ReactApplicationContext context = getRNContext(); | |
// set that hardware is available and fingerprints configured | |
final FingerprintManager fm = (FingerprintManager) context.getSystemService(Context.FINGERPRINT_SERVICE); | |
shadowOf(fm).setIsHardwareDetected(true); | |
shadowOf(fm).setDefaultFingerprints(5); // 5 fingerprints are available | |
// WHEN: fingerprint availability influence on storage selection | |
final KeychainModule module = new KeychainModule(context); | |
final boolean isFingerprintWorking = module.isFingerprintAuthAvailable(); | |
final CipherStorage storage = module.getCipherStorageForCurrentAPILevel(); | |
// THEN: expected RsaEcb with working fingerprint | |
assertThat(isFingerprintWorking, is(true)); | |
assertThat(storage, notNullValue()); | |
assertThat(storage, instanceOf(CipherStorageKeystoreRsaEcb.class)); | |
assertThat(storage.isBiometrySupported(), is(true)); | |
assertThat(storage.securityLevel(), is(SecurityLevel.SECURE_HARDWARE)); | |
assertThat(storage.getMinSupportedApiLevel(), is(Build.VERSION_CODES.M)); | |
assertThat(storage.supportsSecureHardware(), is(true)); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.P) | |
public void testExtractRsaEcb_EnabledFingerprint_api28() throws Exception { | |
// GIVEN: | |
// API28 android version | |
// fingerprint feature enabled | |
// fingerprints configured | |
final ReactApplicationContext context = getRNContext(); | |
shadowOf(context.getPackageManager()).setSystemFeature(PackageManager.FEATURE_FINGERPRINT, true); | |
// set that hardware is available and fingerprints configured | |
final FingerprintManager fm = (FingerprintManager) context.getSystemService(Context.FINGERPRINT_SERVICE); | |
shadowOf(fm).setIsHardwareDetected(true); | |
shadowOf(fm).setDefaultFingerprints(5); // 5 fingerprints are available | |
// WHEN: get secured storage | |
final int result = BiometricManager.from(context).canAuthenticate(); | |
final KeychainModule module = new KeychainModule(context); | |
final boolean isFingerprintWorking = module.isFingerprintAuthAvailable(); | |
final CipherStorage storage = module.getCipherStorageForCurrentAPILevel(); | |
// THEN: expected RsaEcb with working fingerprint | |
assertThat(isFingerprintWorking, is(true)); | |
assertThat(result, is(BiometricManager.BIOMETRIC_SUCCESS)); | |
assertThat(storage, notNullValue()); | |
assertThat(storage, instanceOf(CipherStorageKeystoreRsaEcb.class)); | |
assertThat(storage.isBiometrySupported(), is(true)); | |
assertThat(storage.securityLevel(), is(SecurityLevel.SECURE_HARDWARE)); | |
assertThat(storage.getMinSupportedApiLevel(), is(Build.VERSION_CODES.M)); | |
assertThat(storage.supportsSecureHardware(), is(true)); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.M) | |
public void testMigrateStorageFromOlder_api23() throws Exception { | |
// GIVEN: | |
final ReactApplicationContext context = getRNContext(); | |
final CipherStorage aes = Mockito.mock(CipherStorage.class); | |
final CipherStorage rsa = Mockito.mock(CipherStorage.class); | |
final CipherStorage.DecryptionResult decrypted = new CipherStorage.DecryptionResult("user", "password"); | |
final CipherStorage.EncryptionResult encrypted = new CipherStorage.EncryptionResult("user".getBytes(), "password".getBytes(), rsa); | |
final KeychainModule module = new KeychainModule(context); | |
final SharedPreferences prefs = context.getSharedPreferences(PrefsStorage.KEYCHAIN_DATA, Context.MODE_PRIVATE); | |
when( | |
rsa.encrypt(eq("dummy"), eq("user"), eq("password"), any()) | |
).thenReturn(encrypted); | |
when(rsa.getCipherStorageName()).thenReturn("dummy"); | |
// WHEN: | |
module.migrateCipherStorage("dummy", rsa, aes, decrypted); | |
final String username = prefs.getString(PrefsStorage.getKeyForUsername("dummy"), ""); | |
final String password = prefs.getString(PrefsStorage.getKeyForPassword("dummy"), ""); | |
final String cipherName = prefs.getString(PrefsStorage.getKeyForCipherStorage("dummy"), ""); | |
// THEN: | |
// delete of key from old storage | |
// re-store of encrypted data in shared preferences | |
verify(rsa).encrypt("dummy", "user", "password", SecurityLevel.ANY); | |
verify(aes).removeKey("dummy"); | |
// Base64.DEFAULT force '\n' char in the end of string | |
assertThat(username, is("dXNlcg==\n")); | |
assertThat(password, is("cGFzc3dvcmQ=\n")); | |
assertThat(cipherName, is("dummy")); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.P) | |
public void testGetSecurityLevel_Unspecified_api28() throws Exception { | |
// GIVE: | |
final ReactApplicationContext context = getRNContext(); | |
final KeychainModule module = new KeychainModule(context); | |
final Promise mockPromise = mock(Promise.class); | |
// WHEN: | |
module.getSecurityLevel(null, mockPromise); | |
// THEN: | |
verify(mockPromise).resolve(SecurityLevel.SECURE_HARDWARE.name()); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.M) | |
public void testGetSecurityLevel_Unspecified_api23() throws Exception { | |
// GIVE: | |
final ReactApplicationContext context = getRNContext(); | |
final KeychainModule module = new KeychainModule(context); | |
final Promise mockPromise = mock(Promise.class); | |
// WHEN: | |
module.getSecurityLevel(null, mockPromise); | |
// THEN: | |
verify(mockPromise).resolve(SecurityLevel.SECURE_HARDWARE.name()); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.LOLLIPOP) | |
public void testGetSecurityLevel_Unspecified_api21() throws Exception { | |
// GIVE: | |
final ReactApplicationContext context = getRNContext(); | |
final KeychainModule module = new KeychainModule(context); | |
final Promise mockPromise = mock(Promise.class); | |
// WHEN: | |
module.getSecurityLevel(null, mockPromise); | |
// THEN: | |
verify(mockPromise).resolve(SecurityLevel.ANY.name()); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.KITKAT) | |
public void testGetSecurityLevel_Unspecified_api19() throws Exception { | |
// GIVE: | |
final ReactApplicationContext context = getRNContext(); | |
final KeychainModule module = new KeychainModule(context); | |
final Promise mockPromise = mock(Promise.class); | |
// WHEN: | |
module.getSecurityLevel(null, mockPromise); | |
// THEN: | |
verify(mockPromise).resolve(SecurityLevel.ANY.name()); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.P) | |
public void testGetSecurityLevel_NoBiometry_api28() throws Exception { | |
// GIVE: | |
final ReactApplicationContext context = getRNContext(); | |
final KeychainModule module = new KeychainModule(context); | |
final Promise mockPromise = mock(Promise.class); | |
// WHEN: | |
final JavaOnlyMap options = new JavaOnlyMap(); | |
options.putString(Maps.ACCESS_CONTROL, AccessControl.DEVICE_PASSCODE); | |
module.getSecurityLevel(options, mockPromise); | |
// THEN: | |
verify(mockPromise).resolve(SecurityLevel.SECURE_HARDWARE.name()); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.P) | |
public void testGetSecurityLevel_NoBiometry_NoSecuredHardware_api28() throws Exception { | |
// GIVE: | |
final ReactApplicationContext context = getRNContext(); | |
final KeychainModule module = new KeychainModule(context); | |
final Promise mockPromise = mock(Promise.class); | |
// set key info - software method | |
provider.configuration.put("isInsideSecureHardware", false); | |
// WHEN: | |
final JavaOnlyMap options = new JavaOnlyMap(); | |
options.putString(Maps.ACCESS_CONTROL, AccessControl.DEVICE_PASSCODE); | |
module.getSecurityLevel(options, mockPromise); | |
// THEN: | |
// expected AesCbc usage | |
assertThat(provider.mocks.get("KeyGenerator"), notNullValue()); | |
assertThat(provider.mocks.get("KeyGenerator").get("AES"), notNullValue()); | |
assertThat(provider.mocks.get("KeyPairGenerator"), notNullValue()); | |
assertThat(provider.mocks.get("KeyPairGenerator").get("RSA"), notNullValue()); | |
verify(mockPromise).resolve(SecurityLevel.SECURE_SOFTWARE.name()); | |
} | |
@Test | |
@Config(sdk = Build.VERSION_CODES.P) | |
public void testDowngradeBiometricToAes_api28() throws Exception { | |
// GIVEN: | |
final ReactApplicationContext context = getRNContext(); | |
final KeychainModule module = new KeychainModule(context); | |
final PrefsStorage prefs = new PrefsStorage(context); | |
final Cipher mockCipher = Mockito.mock(Cipher.class); | |
final KeyStore mockKeyStore = Mockito.mock(KeyStore.class); | |
final CipherStorage storage = module.getCipherStorageByName(KnownCiphers.RSA); | |
final CipherStorage.EncryptionResult result = new CipherStorage.EncryptionResult(BYTES_USERNAME, BYTES_PASSWORD, storage); | |
final Promise mockPromise = mock(Promise.class); | |
final JavaOnlyMap options = new JavaOnlyMap(); | |
// store record done with RSA/Biometric cipher | |
prefs.storeEncryptedEntry("dummy", result); | |
assertThat(storage, instanceOf(CipherStorage.class)); | |
((CipherStorageBase)storage).setCipher(mockCipher).setKeyStore(mockKeyStore); | |
when(mockKeyStore.getKey(eq("dummy"), isNull())).thenReturn(null); // return empty Key! | |
// WHEN: | |
module.getGenericPasswordForOptions("dummy", options, mockPromise); | |
// THEN: | |
ArgumentCaptor<Exception> exception = ArgumentCaptor.forClass(Exception.class); | |
verify(mockPromise).reject(eq(Errors.E_CRYPTO_FAILED), exception.capture()); | |
assertThat(exception.getValue(), instanceOf(CryptoFailedException.class)); | |
assertThat(exception.getValue().getCause(), instanceOf(KeyStoreAccessException.class)); | |
assertThat(exception.getValue().getMessage(), is("Wrapped error: Empty key extracted!")); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment