From f7f0d906727c849d28be74ecd202f63f77083dbb Mon Sep 17 00:00:00 2001 From: Oleksandr Kucherenko Date: Mon, 28 Oct 2019 08:43:53 +0100 Subject: [PATCH 01/27] refactored code, implemented androidx biometrics --- .../keystores/release.keystore.properties | 6 + android/build.gradle | 103 ++- android/src/main/AndroidManifest.xml | 20 +- .../oblador/keychain/DeviceAvailability.java | 57 +- .../com/oblador/keychain/KeychainModule.java | 754 ++++++++++++------ .../com/oblador/keychain/KeychainPackage.java | 40 +- .../com/oblador/keychain/PrefsStorage.java | 177 ++-- .../com/oblador/keychain/SecurityLevel.java | 26 +- .../keychain/cipherStorage/CipherStorage.java | 171 +++- .../cipherStorage/CipherStorageBase.java | 489 ++++++++++++ .../CipherStorageFacebookConceal.java | 266 ++++-- ...ate with sufficient security level (#218)} | 28 +- .../CipherStorageKeystoreRsaEcb.java | 302 +++++++ .../exceptions/CryptoFailedException.java | 30 +- .../exceptions/EmptyParameterException.java | 6 +- .../exceptions/KeyStoreAccessException.java | 14 +- .../oblador/keychain/FakeKeyFactorySpi.java | 15 + .../oblador/keychain/FakeKeyGeneratorSpi.java | 13 + .../com/oblador/keychain/FakeKeystore.java | 97 +++ .../com/oblador/keychain/FakeProvider.java | 33 + .../keychain/FakeSecretKeyFactorySpi.java | 17 + .../oblador/keychain/KeychainModuleTests.java | 301 +++++++ .../oblador/keychain/MocksForProvider.java | 83 ++ .../CipherStorageKeystoreAesCbcTests.java | 73 ++ .../CipherStorageKeystoreRsaEcbTests.java | 102 +++ 25 files changed, 2692 insertions(+), 531 deletions(-) create mode 100644 KeychainExample/android/keystores/release.keystore.properties create mode 100644 android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.java rename android/src/main/java/com/oblador/keychain/cipherStorage/{CipherStorageKeystoreAESCBC.java => CipherStorageKeystoreAESCBC.java~fix: remove key if failed to generate with sufficient security level (#218)} (92%) create mode 100644 android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.java create mode 100644 android/src/test/java/com/oblador/keychain/FakeKeyFactorySpi.java create mode 100644 android/src/test/java/com/oblador/keychain/FakeKeyGeneratorSpi.java create mode 100644 android/src/test/java/com/oblador/keychain/FakeKeystore.java create mode 100644 android/src/test/java/com/oblador/keychain/FakeProvider.java create mode 100644 android/src/test/java/com/oblador/keychain/FakeSecretKeyFactorySpi.java create mode 100644 android/src/test/java/com/oblador/keychain/KeychainModuleTests.java create mode 100644 android/src/test/java/com/oblador/keychain/MocksForProvider.java create mode 100644 android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbcTests.java create mode 100644 android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcbTests.java diff --git a/KeychainExample/android/keystores/release.keystore.properties b/KeychainExample/android/keystores/release.keystore.properties new file mode 100644 index 00000000..a1e940e1 --- /dev/null +++ b/KeychainExample/android/keystores/release.keystore.properties @@ -0,0 +1,6 @@ +# generated at 2019-11-06_10-58-40 / 1573034320 +key.store=release.keystore +key.alias=release-key +key.store.password=CeY3AuKCTJhY6nhBRWIHOD+ZgNbrDhUZPqFPHiFvUcc= +key.alias.password=dfyWUW1ge/Bgrm6k3sTuRDEBuJVdgv0qN8v+cAi8VOo= + diff --git a/android/build.gradle b/android/build.gradle index bd2fe043..4ba750c8 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,55 +1,78 @@ -def safeExtGet(prop, fallback) { - rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback -} - buildscript { - // The Android Gradle plugin is only required when opening the android folder stand-alone. - // This avoids unnecessary downloads and potential conflicts when the library is included as a - // module dependency in an application project. - if (project == rootProject) { - repositories { - google() - jcenter() - } - dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' - } + repositories { + maven { + url 'https://plugins.gradle.org/m2/' } + } + dependencies { + classpath 'com.adarshr:gradle-test-logger-plugin:2.0.0' + } } apply plugin: 'com.android.library' +apply plugin: "com.adarshr.test-logger" + +def safeExtGet(prop, fallback) { + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback +} android { - compileSdkVersion safeExtGet('compileSdkVersion', 28) - buildToolsVersion safeExtGet('buildToolsVersion', '28.0.3') - defaultConfig { - minSdkVersion safeExtGet('minSdkVersion', 16) - targetSdkVersion safeExtGet('targetSdkVersion', 26) - versionCode 1 - versionName "1.0" - } - lintOptions { - abortOnError false + compileSdkVersion safeExtGet('compileSdkVersion', 28) + buildToolsVersion safeExtGet('buildToolsVersion', '28.0.3') + + defaultConfig { + minSdkVersion safeExtGet('minSdkVersion', 19) + targetSdkVersion safeExtGet('targetSdkVersion', 28) + versionCode 1 + versionName "1.0" + } + lintOptions { + abortOnError false + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + // Gradle automatically adds 'android.test.runner' as a dependency. + useLibrary 'android.test.runner' + useLibrary 'android.test.base' + useLibrary 'android.test.mock' + + testOptions { + unitTests { + includeAndroidResources = true } + } } repositories { - mavenLocal() - maven { - // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm - url "$rootDir/../node_modules/react-native/android" - } - maven { - // Android JSC is installed from npm - url "$rootDir/../node_modules/jsc-android/dist" - } - google() - jcenter() + mavenCentral() } dependencies { - //noinspection GradleDynamicVersion - implementation 'com.facebook.react:react-native:+' // From node_modules - implementation 'androidx.annotation:annotation:1.1.0' - implementation 'com.facebook.conceal:conceal:1.1.3@aar' + implementation 'com.facebook.react:react-native:+' + + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + + /* https://mvnrepository.com/artifact/androidx.biometric/biometric + src: http://bit.ly/31DhLZG */ + implementation 'androidx.biometric:biometric:1.0.0-rc02@aar' + + /* version higher 1.1.3 has problems with included soloader packages, + https://github.com/facebook/conceal/releases */ + implementation "com.facebook.conceal:conceal:1.1.3@aar" + + /* Unit Testing Frameworks */ + testImplementation "junit:junit:4.12" + + /* Mockito, https://mvnrepository.com/artifact/org.mockito/mockito-inline */ + testImplementation "org.mockito:mockito-inline:3.1.0" + + /* https://mvnrepository.com/artifact/org.hamcrest/hamcrest/2.1 */ + testImplementation "org.hamcrest:hamcrest:2.1" + + /* http://robolectric.org/getting-started/ */ + testImplementation("org.robolectric:robolectric:4.3.1") } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index ddbe5066..54446d41 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,4 +1,18 @@ - - + + + + + + + + + + diff --git a/android/src/main/java/com/oblador/keychain/DeviceAvailability.java b/android/src/main/java/com/oblador/keychain/DeviceAvailability.java index 090d47bb..d6c7900b 100644 --- a/android/src/main/java/com/oblador/keychain/DeviceAvailability.java +++ b/android/src/main/java/com/oblador/keychain/DeviceAvailability.java @@ -1,24 +1,51 @@ package com.oblador.keychain; -import android.os.Build; -import android.content.Context; +import android.Manifest; import android.app.KeyguardManager; -import android.hardware.fingerprint.FingerprintManager; +import android.content.Context; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.biometric.BiometricManager; + +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS; +/** + * @see Biometric hradware + */ +@SuppressWarnings("WeakerAccess") public class DeviceAvailability { - public static boolean isFingerprintAuthAvailable(Context context) { - if (android.os.Build.VERSION.SDK_INT >= 23) { - FingerprintManager fingerprintManager = - (FingerprintManager) context.getSystemService(Context.FINGERPRINT_SERVICE); - return fingerprintManager != null && fingerprintManager.isHardwareDetected() && - fingerprintManager.hasEnrolledFingerprints(); - } - return false; + public static boolean isFingerprintAuthAvailable(@NonNull final Context context) { + return BiometricManager.from(context).canAuthenticate() == BIOMETRIC_SUCCESS; + } + + /** Check is permissions granted for biometric things. */ + public static boolean isPermissionsGranted(@NonNull final Context context) { + // before api23 no permissions for biometric, no hardware == no permissions + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false; } - public static boolean isDeviceSecure(Context context) { - KeyguardManager keyguardManager = - (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); - return Build.VERSION.SDK_INT >= 23 && keyguardManager != null && keyguardManager.isDeviceSecure(); + final KeyguardManager km = + (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + if( !km.isKeyguardSecure() ) return false; + + // api28+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return context.checkSelfPermission(Manifest.permission.USE_BIOMETRIC) == PERMISSION_GRANTED; } + + // before api28 + return context.checkSelfPermission(Manifest.permission.USE_FINGERPRINT) == PERMISSION_GRANTED; + } + + public static boolean isDeviceSecure(@NonNull final Context context) { + final KeyguardManager km = + (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + km != null && + km.isDeviceSecure(); + } } diff --git a/android/src/main/java/com/oblador/keychain/KeychainModule.java b/android/src/main/java/com/oblador/keychain/KeychainModule.java index 61d061e8..451bc6fe 100644 --- a/android/src/main/java/com/oblador/keychain/KeychainModule.java +++ b/android/src/main/java/com/oblador/keychain/KeychainModule.java @@ -1,10 +1,17 @@ package com.oblador.keychain; import android.os.Build; -import androidx.annotation.NonNull; +import android.os.Looper; +import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.biometric.BiometricPrompt; +import androidx.fragment.app.FragmentActivity; + import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.AssertionException; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; @@ -13,291 +20,586 @@ import com.facebook.react.bridge.WritableMap; import com.oblador.keychain.PrefsStorage.ResultSet; import com.oblador.keychain.cipherStorage.CipherStorage; +import com.oblador.keychain.cipherStorage.CipherStorage.DecryptionContext; import com.oblador.keychain.cipherStorage.CipherStorage.DecryptionResult; +import com.oblador.keychain.cipherStorage.CipherStorage.DecryptionResultHandler; import com.oblador.keychain.cipherStorage.CipherStorage.EncryptionResult; +import com.oblador.keychain.cipherStorage.CipherStorageBase; import com.oblador.keychain.cipherStorage.CipherStorageFacebookConceal; -import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAESCBC; +import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAesCbc; +import com.oblador.keychain.cipherStorage.CipherStorageKeystoreRsaEcb; +import com.oblador.keychain.cipherStorage.CipherStorageKeystoreRsaEcb.NonInteractiveHandler; import com.oblador.keychain.exceptions.CryptoFailedException; import com.oblador.keychain.exceptions.EmptyParameterException; import com.oblador.keychain.exceptions.KeyStoreAccessException; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; -import javax.annotation.Nullable; +import static com.oblador.keychain.SecurityLevel.ANY; +import static com.oblador.keychain.SecurityLevel.SECURE_HARDWARE; +import static com.oblador.keychain.SecurityLevel.SECURE_SOFTWARE; +@SuppressWarnings({"unused", "WeakerAccess"}) public class KeychainModule extends ReactContextBaseJavaModule { + //region Constants + public static final String KEYCHAIN_MODULE = "RNKeychainManager"; + public static final String FINGERPRINT_SUPPORTED_NAME = "Fingerprint"; + public static final String EMPTY_STRING = ""; + public static final String ACCESS_CONTROL_BIOMETRY_ANY = "BiometryAny"; + public static final String ACCESS_CONTROL_BIOMETRY_CURRENT_SET = "BiometryCurrentSet"; + + @interface Maps { + String SERVICE = "service"; + String USERNAME = "username"; + String PASSWORD = "password"; + } + + @interface Errors { + String E_EMPTY_PARAMETERS = "E_EMPTY_PARAMETERS"; + String E_CRYPTO_FAILED = "E_CRYPTO_FAILED"; + String E_KEYSTORE_ACCESS_ERROR = "E_KEYSTORE_ACCESS_ERROR"; + String E_SUPPORTED_BIOMETRY_ERROR = "E_SUPPORTED_BIOMETRY_ERROR"; + /** Raised for unexpected errors. */ + String E_UNKNOWN_ERROR = "E_UNKNOWN_ERROR"; + } + + //endregion + + //region Members + /** Name-to-instance lookup map. */ + private final Map cipherStorageMap = new HashMap<>(); + /** Shared preferences storage. */ + private final PrefsStorage prefsStorage; + //endregion + + public KeychainModule(@NonNull final ReactApplicationContext reactContext) { + super(reactContext); + prefsStorage = new PrefsStorage(reactContext); + + addCipherStorageToMap(new CipherStorageFacebookConceal(reactContext)); + addCipherStorageToMap(new CipherStorageKeystoreAesCbc()); + + // we have a references to newer api that will fail load of app classes in old androids OS + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + addCipherStorageToMap(new CipherStorageKeystoreRsaEcb()); + } + } + + //region Overrides + + /** {@inheritDoc} */ + @Override + @NonNull + public String getName() { + return KEYCHAIN_MODULE; + } + + /** {@inheritDoc} */ + @NonNull + @Override + public Map getConstants() { + final Map constants = new HashMap<>(); + + constants.put(ANY.jsName(), ANY.name()); + constants.put(SECURE_SOFTWARE.jsName(), SECURE_SOFTWARE.name()); + constants.put(SECURE_HARDWARE.jsName(), SECURE_HARDWARE.name()); + + return constants; + } + //endregion + + //region React Methods + @ReactMethod + public void getSecurityLevel(@NonNull final String accessControl, + @NonNull final Promise promise) { + final boolean useBiometry = getUseBiometry(accessControl); + + promise.resolve(getSecurityLevel().name()); + } + + @ReactMethod + public void setGenericPasswordForOptions(@Nullable final String service, + @NonNull final String username, + @NonNull final String password, + @NonNull final String minimumSecurityLevel, + @NonNull final Promise promise) { + try { + throwIfEmptyLoginPassword(username, password); + + final SecurityLevel level = SecurityLevel.valueOf(minimumSecurityLevel); + final String safeService = getDefaultServiceIfNull(service); + final CipherStorage storage = getCipherStorageForCurrentAPILevel(); + + throwIfInsufficientLevel(storage, level); + + final EncryptionResult result = storage.encrypt(safeService, username, password, level); + prefsStorage.storeEncryptedEntry(safeService, result); + + promise.resolve(true); + } catch (EmptyParameterException e) { + Log.e(KEYCHAIN_MODULE, e.getMessage()); + + promise.reject(Errors.E_EMPTY_PARAMETERS, e); + } catch (CryptoFailedException e) { + Log.e(KEYCHAIN_MODULE, e.getMessage()); + + promise.reject(Errors.E_CRYPTO_FAILED, e); + } catch (Throwable fail) { + Log.e(KEYCHAIN_MODULE, fail.getMessage(), fail); + + promise.reject(Errors.E_UNKNOWN_ERROR, fail); + } + } + + @ReactMethod + public void getGenericPasswordForOptions(@Nullable final String service, + @NonNull final Promise promise) { + try { + final String safeService = getDefaultServiceIfNull(service); + final CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel(); + final ResultSet resultSet = prefsStorage.getEncryptedEntry(safeService); + + if (resultSet == null) { + Log.e(KEYCHAIN_MODULE, "No entry found for service: " + service); + promise.resolve(false); + return; + } + + final DecryptionResult decryptionResult = decryptCredentials(safeService, currentCipherStorage, resultSet); + + final WritableMap credentials = Arguments.createMap(); + credentials.putString(Maps.SERVICE, safeService); + credentials.putString(Maps.USERNAME, decryptionResult.username); + credentials.putString(Maps.PASSWORD, decryptionResult.password); + + promise.resolve(credentials); + } catch (KeyStoreAccessException e) { + Log.e(KEYCHAIN_MODULE, e.getMessage()); + promise.reject(Errors.E_KEYSTORE_ACCESS_ERROR, e); + } catch (CryptoFailedException e) { + Log.e(KEYCHAIN_MODULE, e.getMessage()); + promise.reject(Errors.E_CRYPTO_FAILED, e); + } catch (Throwable fail) { + Log.e(KEYCHAIN_MODULE, fail.getMessage(), fail); + + promise.reject(Errors.E_UNKNOWN_ERROR, fail); + } + } + + @ReactMethod + public void resetGenericPasswordForOptions(@Nullable String service, + @NonNull final Promise promise) { + try { + service = getDefaultServiceIfNull(service); + + // First we clean up the cipher storage (using the cipher storage that was used to store the entry) + ResultSet resultSet = prefsStorage.getEncryptedEntry(service); + if (resultSet != null) { + CipherStorage cipherStorage = getCipherStorageByName(resultSet.cipherStorageName); + if (cipherStorage != null) { + cipherStorage.removeKey(service); + } + } + // And then we remove the entry in the shared preferences + prefsStorage.removeEntry(service); + + promise.resolve(true); + } catch (KeyStoreAccessException e) { + Log.e(KEYCHAIN_MODULE, e.getMessage()); + promise.reject(Errors.E_KEYSTORE_ACCESS_ERROR, e); + } catch (Throwable fail) { + Log.e(KEYCHAIN_MODULE, fail.getMessage(), fail); + + promise.reject(Errors.E_UNKNOWN_ERROR, fail); + } + } + + @ReactMethod + public void hasInternetCredentialsForServer(@NonNull String server, + @NonNull final Promise promise) { + final String defaultService = getDefaultServiceIfNull(server); + + ResultSet resultSet = prefsStorage.getEncryptedEntry(defaultService); + if (resultSet == null) { + Log.e(KEYCHAIN_MODULE, "No entry found for service: " + defaultService); + promise.resolve(false); + return; + } - public static final String E_EMPTY_PARAMETERS = "E_EMPTY_PARAMETERS"; - public static final String E_CRYPTO_FAILED = "E_CRYPTO_FAILED"; - public static final String E_KEYSTORE_ACCESS_ERROR = "E_KEYSTORE_ACCESS_ERROR"; - public static final String E_SUPPORTED_BIOMETRY_ERROR = "E_SUPPORTED_BIOMETRY_ERROR"; - public static final String KEYCHAIN_MODULE = "RNKeychainManager"; - public static final String FINGERPRINT_SUPPORTED_NAME = "Fingerprint"; - public static final String EMPTY_STRING = ""; - - private final Map cipherStorageMap = new HashMap<>(); - private final PrefsStorage prefsStorage; + promise.resolve(true); + } + + @ReactMethod + public void setInternetCredentialsForServer(@NonNull String server, + String username, + String password, + String minimumSecurityLevel, + ReadableMap unusedOptions, + @NonNull final Promise promise) { + setGenericPasswordForOptions(server, username, password, minimumSecurityLevel, promise); + } + + @ReactMethod + public void getInternetCredentialsForServer(@NonNull String server, + ReadableMap unusedOptions, + @NonNull final Promise promise) { + getGenericPasswordForOptions(server, promise); + } + + @ReactMethod + public void resetInternetCredentialsForServer(@NonNull String server, + ReadableMap unusedOptions, + @NonNull final Promise promise) { + resetGenericPasswordForOptions(server, promise); + } + + @ReactMethod + public void getSupportedBiometryType(@NonNull final Promise promise) { + try { + boolean fingerprintAuthAvailable = isFingerprintAuthAvailable(); + + if (fingerprintAuthAvailable) { + promise.resolve(FINGERPRINT_SUPPORTED_NAME); + } else { + promise.resolve(null); + } + } catch (Exception e) { + Log.e(KEYCHAIN_MODULE, e.getMessage(), e); + + promise.reject(Errors.E_SUPPORTED_BIOMETRY_ERROR, e); + } catch (Throwable fail) { + Log.e(KEYCHAIN_MODULE, fail.getMessage(), fail); + + promise.reject(Errors.E_UNKNOWN_ERROR, fail); + } + } + //endregion + + //region Implementation + + /** Is provided access control string matching biometry use request? */ + public static boolean getUseBiometry(@Nullable final String accessControl) { + return accessControl != null + && (accessControl.equals(ACCESS_CONTROL_BIOMETRY_ANY) + || accessControl.equals(ACCESS_CONTROL_BIOMETRY_CURRENT_SET)); + } + + private void addCipherStorageToMap(@NonNull final CipherStorage cipherStorage) { + cipherStorageMap.put(cipherStorage.getCipherStorageName(), cipherStorage); + } + + /** + * Extract credentials from current storage. In case if current storage is not matching + * results set then executed migration. + */ + @NonNull + private DecryptionResult decryptCredentials(@NonNull final String alias, + @NonNull final CipherStorage current, + @NonNull final ResultSet resultSet) + throws CryptoFailedException, KeyStoreAccessException { + final String storageName = resultSet.cipherStorageName; + + // The encrypted data is encrypted using the current CipherStorage, so we just decrypt and return + if (storageName.equals(current.getCipherStorageName())) { + final DecryptionResultHandler handler = getInteractiveHandler(current); + current.decrypt(handler, alias, resultSet.username, resultSet.password, ANY); + + CryptoFailedException.reThrowOnError(handler.getError()); + + if (null == handler.getResult()) { + throw new CryptoFailedException("No decryption results and no error. Something deeply wrong!"); + } + + return handler.getResult(); + } - @Override - public String getName() { - return KEYCHAIN_MODULE; + // The encrypted data is encrypted using an older CipherStorage, so we need to decrypt the data first, then encrypt it using the current CipherStorage, then store it again and return + final CipherStorage oldStorage = getCipherStorageByName(storageName); + if (null == oldStorage) { + throw new KeyStoreAccessException("Wrong cipher storage name: " + storageName); } - public KeychainModule(ReactApplicationContext reactContext) { - super(reactContext); - prefsStorage = new PrefsStorage(reactContext); + // decrypt using the older cipher storage + final DecryptionResult decryptionResult = oldStorage.decrypt( + alias, resultSet.username, resultSet.password, ANY); - addCipherStorageToMap(new CipherStorageFacebookConceal(reactContext)); - addCipherStorageToMap(new CipherStorageKeystoreAESCBC()); + try { + // encrypt using the current cipher storage + migrateCipherStorage(alias, current, oldStorage, decryptionResult); + } catch (CryptoFailedException e) { + Log.w(KEYCHAIN_MODULE, "Migrating to a less safe storage is not allowed. Keeping the old one"); } - private void addCipherStorageToMap(CipherStorage cipherStorage) { - cipherStorageMap.put(cipherStorage.getCipherStorageName(), cipherStorage); - } + return decryptionResult; + } - @Nullable - @Override - public Map getConstants() { - final Map constants = new HashMap<>(); - constants.put(SecurityLevel.ANY.jsName(), SecurityLevel.ANY.name()); - constants.put(SecurityLevel.SECURE_SOFTWARE.jsName(), SecurityLevel.SECURE_SOFTWARE.name()); - constants.put(SecurityLevel.SECURE_HARDWARE.jsName(), SecurityLevel.SECURE_HARDWARE.name()); - return constants; + /** Get instance of handler that resolves access to the keystore on system request. */ + @NonNull + protected DecryptionResultHandler getInteractiveHandler(@NonNull final CipherStorage current) { + if (current.isBiometrySupported() && isFingerprintAuthAvailable()) { + return new InteractiveBiometric(current); } - @ReactMethod - public void getSecurityLevel(Promise promise) { - promise.resolve(getSecurityLevel().name()); + return new NonInteractiveHandler(); + } + + /** Remove key from old storage and add it to the new storage. */ + /* package */ void migrateCipherStorage(@NonNull final String service, + @NonNull final CipherStorage newCipherStorage, + @NonNull final CipherStorage oldCipherStorage, + @NonNull final DecryptionResult decryptionResult) + throws KeyStoreAccessException, CryptoFailedException { + + // don't allow to degrade security level when transferring, the new + // storage should be as safe as the old one. + final EncryptionResult encryptionResult = newCipherStorage.encrypt( + service, decryptionResult.username, decryptionResult.password, + decryptionResult.getSecurityLevel()); + + // store the encryption result + prefsStorage.storeEncryptedEntry(service, encryptionResult); + + // clean up the old cipher storage + oldCipherStorage.removeKey(service); + } + + /** + * The "Current" CipherStorage is the cipherStorage with the highest API level that is + * lower than or equal to the current API level + */ + @NonNull + /* package */ CipherStorage getCipherStorageForCurrentAPILevel() throws CryptoFailedException { + final int currentApiLevel = Build.VERSION.SDK_INT; + final boolean isFingerprint = isFingerprintAuthAvailable(); + CipherStorage foundCipher = null; + + for (CipherStorage variant : cipherStorageMap.values()) { + Log.d(KEYCHAIN_MODULE, "Probe cipher storage: " + variant.getClass().getSimpleName()); + + // Is the cipherStorage supported on the current API level? + final int minApiLevel = variant.getMinSupportedApiLevel(); + final int capabilityLevel = variant.getCapabilityLevel(); + final boolean isSupportedApi = (minApiLevel <= currentApiLevel); + + // API not supported + if (!isSupportedApi) continue; + + // Is the API level better than the one we previously selected (if any)? + if (foundCipher != null && capabilityLevel < foundCipher.getCapabilityLevel()) continue; + + // if biometric supported but not configured properly than skip + if (variant.isBiometrySupported() && !isFingerprint) continue; + + // remember storage with the best capabilities + foundCipher = variant; } - @ReactMethod - public void setGenericPasswordForOptions(String service, String username, String password, String minimumSecurityLevel, Promise promise) { - try { - SecurityLevel level = SecurityLevel.valueOf(minimumSecurityLevel); - if (username == null || username.isEmpty() || password == null || password.isEmpty()) { - throw new EmptyParameterException("you passed empty or null username/password"); - } - service = getDefaultServiceIfNull(service); - - CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel(); - validateCipherStorageSecurityLevel(currentCipherStorage, level); - - EncryptionResult result = currentCipherStorage.encrypt(service, username, password, level); - prefsStorage.storeEncryptedEntry(service, result); - - promise.resolve(true); - } catch (EmptyParameterException e) { - Log.e(KEYCHAIN_MODULE, e.getMessage()); - promise.reject(E_EMPTY_PARAMETERS, e); - } catch (CryptoFailedException e) { - Log.e(KEYCHAIN_MODULE, e.getMessage()); - promise.reject(E_CRYPTO_FAILED, e); - } + if (foundCipher == null) { + throw new CryptoFailedException("Unsupported Android SDK " + Build.VERSION.SDK_INT); } - @ReactMethod - public void getGenericPasswordForOptions(String service, Promise promise) { - try { - service = getDefaultServiceIfNull(service); - - CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel(); + Log.d(KEYCHAIN_MODULE, "Selected storage: " + foundCipher.getClass().getSimpleName()); - ResultSet resultSet = prefsStorage.getEncryptedEntry(service); - if (resultSet == null) { - Log.e(KEYCHAIN_MODULE, "No entry found for service: " + service); - promise.resolve(false); - return; - } + return foundCipher; + } - final DecryptionResult decryptionResult = decryptCredentials(service, currentCipherStorage, resultSet); - - WritableMap credentials = Arguments.createMap(); - - credentials.putString("service", service); - credentials.putString("username", decryptionResult.username); - credentials.putString("password", decryptionResult.password); + /** Throw exception in case of empty credentials providing. */ + public static void throwIfEmptyLoginPassword(@Nullable final String username, + @Nullable final String password) + throws EmptyParameterException { + if (TextUtils.isEmpty(username) || TextUtils.isEmpty(password)) { + throw new EmptyParameterException("you passed empty or null username/password"); + } + } + + /** Throw exception if required security level does not match storage provided security level. */ + public static void throwIfInsufficientLevel(@NonNull final CipherStorage storage, + @NonNull final SecurityLevel level) + throws CryptoFailedException { + if (storage.securityLevel().satisfiesSafetyThreshold(level)) { + return; + } - promise.resolve(credentials); - } catch (KeyStoreAccessException e) { - Log.e(KEYCHAIN_MODULE, e.getMessage()); - promise.reject(E_KEYSTORE_ACCESS_ERROR, e); - } catch (CryptoFailedException e) { - Log.e(KEYCHAIN_MODULE, e.getMessage()); - promise.reject(E_CRYPTO_FAILED, e); - } + throw new CryptoFailedException( + String.format( + "Cipher Storage is too weak. Required security level is: %s, but only %s is provided", + level.name(), + storage.securityLevel().name())); + } + + @Nullable + private CipherStorage getCipherStorageByName(@NonNull final String cipherStorageName) { + return cipherStorageMap.get(cipherStorageName); + } + + /** True - if fingerprint hardware available and configured, otherwise false. */ + /* package */ boolean isFingerprintAuthAvailable() { + return DeviceAvailability.isFingerprintAuthAvailable(getReactApplicationContext()); + } + + /** Is secured hardware a part of current storage or not. */ + /* package */ boolean isSecureHardwareAvailable() { + try { + return getCipherStorageForCurrentAPILevel().supportsSecureHardware(); + } catch (CryptoFailedException e) { + return false; } + } - private DecryptionResult decryptCredentials(String service, CipherStorage currentCipherStorage, ResultSet resultSet) throws CryptoFailedException, KeyStoreAccessException { - if (resultSet.cipherStorageName.equals(currentCipherStorage.getCipherStorageName())) { - // The encrypted data is encrypted using the current CipherStorage, so we just decrypt and return - return currentCipherStorage.decrypt(service, resultSet.usernameBytes, resultSet.passwordBytes); - } + /** Resolve storage to security level it provides. */ + @NonNull + private SecurityLevel getSecurityLevel() { + try { + final CipherStorage storage = getCipherStorageForCurrentAPILevel(); - // The encrypted data is encrypted using an older CipherStorage, so we need to decrypt the data first, then encrypt it using the current CipherStorage, then store it again and return - CipherStorage oldCipherStorage = getCipherStorageByName(resultSet.cipherStorageName); - // decrypt using the older cipher storage + if (!storage.securityLevel().satisfiesSafetyThreshold(SECURE_SOFTWARE)) { + return ANY; + } - DecryptionResult decryptionResult = oldCipherStorage.decrypt(service, resultSet.usernameBytes, resultSet.passwordBytes); - // encrypt using the current cipher storage + if (isSecureHardwareAvailable()) { + return SECURE_HARDWARE; + } - try { - migrateCipherStorage(service, currentCipherStorage, oldCipherStorage, decryptionResult); - } catch (CryptoFailedException e) { - Log.e(KEYCHAIN_MODULE, "Migrating to a less safe storage is not allowed. Keeping the old one"); - } + return SECURE_SOFTWARE; + } catch (CryptoFailedException e) { + Log.w(KEYCHAIN_MODULE, "Security Level Exception: " + e.getMessage(), e); - return decryptionResult; + return ANY; } - - private void migrateCipherStorage(String service, CipherStorage newCipherStorage, CipherStorage oldCipherStorage, DecryptionResult decryptionResult) throws KeyStoreAccessException, CryptoFailedException { - // don't allow to degrade security level when transferring, the new storage should be as safe as the old one. - EncryptionResult encryptionResult = newCipherStorage.encrypt(service, decryptionResult.username, decryptionResult.password, decryptionResult.getSecurityLevel()); - // store the encryption result - prefsStorage.storeEncryptedEntry(service, encryptionResult); - // clean up the old cipher storage - oldCipherStorage.removeKey(service); + } + + @NonNull + private String getDefaultServiceIfNull(@Nullable final String service) { + return service == null ? EMPTY_STRING : service; + } + //endregion + + //region Nested declarations + + /** Interactive user questioning for biometric data providing. */ + private class InteractiveBiometric extends BiometricPrompt.AuthenticationCallback implements DecryptionResultHandler { + private DecryptionResult result; + private Throwable error; + private final CipherStorageBase storage; + private final Executor executor = Executors.newSingleThreadExecutor(); + private DecryptionContext context; + + private InteractiveBiometric(@NonNull final CipherStorage storage) { + this.storage = (CipherStorageBase) storage; } - @ReactMethod - public void resetGenericPasswordForOptions(String service, Promise promise) { - try { - service = getDefaultServiceIfNull(service); - - // First we clean up the cipher storage (using the cipher storage that was used to store the entry) - ResultSet resultSet = prefsStorage.getEncryptedEntry(service); - if (resultSet != null) { - CipherStorage cipherStorage = getCipherStorageByName(resultSet.cipherStorageName); - if (cipherStorage != null) { - cipherStorage.removeKey(service); - } - } - // And then we remove the entry in the shared preferences - prefsStorage.removeEntry(service); - - promise.resolve(true); - } catch (KeyStoreAccessException e) { - Log.e(KEYCHAIN_MODULE, e.getMessage()); - promise.reject(E_KEYSTORE_ACCESS_ERROR, e); - } - } - - @ReactMethod - public void hasInternetCredentialsForServer(@NonNull String server, Promise promise) { - final String defaultService = getDefaultServiceIfNull(server); - - ResultSet resultSet = prefsStorage.getEncryptedEntry(defaultService); - if (resultSet == null) { - Log.e(KEYCHAIN_MODULE, "No entry found for service: " + defaultService); - promise.resolve(false); - return; - } + @Override + public void askAccessPermissions(@NonNull final DecryptionContext context) { + this.context = context; - promise.resolve(true); - } + if (!DeviceAvailability.isPermissionsGranted(getReactApplicationContext())) { + final CryptoFailedException failure = new CryptoFailedException( + "Could not start fingerprint Authentication. No permissions granted."); - @ReactMethod - public void setInternetCredentialsForServer(@NonNull String server, String username, String password, String minimumSecurityLevel, ReadableMap unusedOptions, Promise promise) { - setGenericPasswordForOptions(server, username, password, minimumSecurityLevel, promise); + onDecrypt(null, failure); + } else { + startAuthentication(); + } } - @ReactMethod - public void getInternetCredentialsForServer(@NonNull String server, ReadableMap unusedOptions, Promise promise) { - getGenericPasswordForOptions(server, promise); - } + @Override + public void onDecrypt(@Nullable final DecryptionResult decryptionResult, @Nullable final Throwable error) { + this.result = decryptionResult; + this.error = error; - @ReactMethod - public void resetInternetCredentialsForServer(@NonNull String server, ReadableMap unusedOptions, Promise promise) { - resetGenericPasswordForOptions(server, promise); + synchronized (this) { + notifyAll(); + } } - @ReactMethod - public void getSupportedBiometryType(Promise promise) { - try { - boolean fingerprintAuthAvailable = isFingerprintAuthAvailable(); - if (fingerprintAuthAvailable) { - promise.resolve(FINGERPRINT_SUPPORTED_NAME); - } else { - promise.resolve(null); - } - } catch (Exception e) { - Log.e(KEYCHAIN_MODULE, e.getMessage()); - promise.reject(E_SUPPORTED_BIOMETRY_ERROR, e); - } + @Nullable + @Override + public DecryptionResult getResult() { + return result; } - // The "Current" CipherStorage is the cipherStorage with the highest API level that is lower than or equal to the current API level - private CipherStorage getCipherStorageForCurrentAPILevel() throws CryptoFailedException { - int currentAPILevel = Build.VERSION.SDK_INT; - CipherStorage currentCipherStorage = null; - for (CipherStorage cipherStorage : cipherStorageMap.values()) { - int cipherStorageAPILevel = cipherStorage.getMinSupportedApiLevel(); - // Is the cipherStorage supported on the current API level? - boolean isSupported = (cipherStorageAPILevel <= currentAPILevel); - if (!isSupported) { - continue; - } - // Is the API level better than the one we previously selected (if any)? - if (currentCipherStorage == null || cipherStorageAPILevel > currentCipherStorage.getMinSupportedApiLevel()) { - currentCipherStorage = cipherStorage; - } - } - if (currentCipherStorage == null) { - throw new CryptoFailedException("Unsupported Android SDK " + Build.VERSION.SDK_INT); - } - return currentCipherStorage; + @Nullable + @Override + public Throwable getError() { + return error; } - private void validateCipherStorageSecurityLevel(CipherStorage cipherStorage, SecurityLevel requiredLevel) throws CryptoFailedException { - if (cipherStorage.securityLevel().satisfiesSafetyThreshold(requiredLevel)) { - return; - } + /** Called when an unrecoverable error has been encountered and the operation is complete. */ + @Override + public void onAuthenticationError(final int errorCode, @NonNull final CharSequence errString) { + final CryptoFailedException error = new CryptoFailedException("code: " + errorCode + ", msg: " + errString); - throw new CryptoFailedException( - String.format( - "Cipher Storage is too weak. Required security level is: %s, but only %s is provided", - requiredLevel.name(), - cipherStorage.securityLevel().name())); + onDecrypt(null, error); } - - private CipherStorage getCipherStorageByName(String cipherStorageName) { - return cipherStorageMap.get(cipherStorageName); + /** Called when a biometric is recognized. */ + @Override + public void onAuthenticationSucceeded(@NonNull final BiometricPrompt.AuthenticationResult result) { + try { + if (null == context) throw new NullPointerException("Decrypt context is not assigned yet."); + + final DecryptionResult decrypted = new DecryptionResult( + storage.decryptBytes(context.key, context.username), + storage.decryptBytes(context.key, context.password) + ); + + onDecrypt(decrypted, null); + } catch (Throwable fail) { + onDecrypt(null, fail); + } } - private boolean isFingerprintAuthAvailable() { - return DeviceAvailability.isFingerprintAuthAvailable(getReactApplicationContext()); - } + /** Called when a biometric is valid but not recognized. */ + @Override + public void onAuthenticationFailed() { + final CryptoFailedException error = new CryptoFailedException("Authentication failed. User Not recognized."); - private boolean isSecureHardwareAvailable() { - try { - return getCipherStorageForCurrentAPILevel().supportsSecureHardware(); - } catch (CryptoFailedException e) { - return false; - } + onDecrypt(null, error); } - private SecurityLevel getSecurityLevel() { - try { - CipherStorage storage = getCipherStorageForCurrentAPILevel(); - if (!storage.securityLevel().satisfiesSafetyThreshold(SecurityLevel.SECURE_SOFTWARE)) { - return SecurityLevel.ANY; - } - - if (isSecureHardwareAvailable()) { - return SecurityLevel.SECURE_HARDWARE; - } else { - return SecurityLevel.SECURE_SOFTWARE; - } - } catch (CryptoFailedException e) { - return SecurityLevel.ANY; - } + /** trigger interactive authentication. */ + public void startAuthentication() { + final FragmentActivity activity = (FragmentActivity) getCurrentActivity(); + if (null == activity) throw new NullPointerException("Not assigned current activity"); + + // code can be executed only from MAIN thread + if (Thread.currentThread() != Looper.getMainLooper().getThread()) { + activity.runOnUiThread(this::startAuthentication); + waitResult(); + return; + } + + final BiometricPrompt prompt = new BiometricPrompt(activity, executor, this); + final BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder() + .setTitle("Authentication required") + .setNegativeButtonText("Cancel") + .setSubtitle("Please use biometric authentication to unlock the app") + .build(); + + prompt.authenticate(info); } + /** Block current NON-main thread and wait for user authentication results. */ + @Override + public void waitResult() { + if (Thread.currentThread() == Looper.getMainLooper().getThread()) + throw new AssertionException("method should not be executed from MAIN thread"); + + Log.i(KEYCHAIN_MODULE, "blocking thread. waiting for done UI operation."); + try { + synchronized (this) { + wait(); + } + } catch (InterruptedException ignored) { + /* shutdown sequence */ + } - @NonNull - private String getDefaultServiceIfNull(String service) { - return service == null ? EMPTY_STRING : service; + Log.i(KEYCHAIN_MODULE, "unblocking thread."); } + } + //endregion } diff --git a/android/src/main/java/com/oblador/keychain/KeychainPackage.java b/android/src/main/java/com/oblador/keychain/KeychainPackage.java index d3aa6511..1c4f6f02 100644 --- a/android/src/main/java/com/oblador/keychain/KeychainPackage.java +++ b/android/src/main/java/com/oblador/keychain/KeychainPackage.java @@ -12,24 +12,24 @@ public class KeychainPackage implements ReactPackage { - public KeychainPackage() { - - } - - @Override - public List createNativeModules( - ReactApplicationContext reactContext) { - List modules = new ArrayList<>(); - modules.add(new KeychainModule(reactContext)); - return modules; - } - - public List> createJSModules() { - return Collections.emptyList(); - } - - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } + public KeychainPackage() { + + } + + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new KeychainModule(reactContext)); + return modules; + } + + public List> createJSModules() { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } } diff --git a/android/src/main/java/com/oblador/keychain/PrefsStorage.java b/android/src/main/java/com/oblador/keychain/PrefsStorage.java index 99448b16..4b4d0576 100644 --- a/android/src/main/java/com/oblador/keychain/PrefsStorage.java +++ b/android/src/main/java/com/oblador/keychain/PrefsStorage.java @@ -2,103 +2,124 @@ import android.content.Context; import android.content.SharedPreferences; -import androidx.annotation.NonNull; import android.util.Base64; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.facebook.react.bridge.ReactApplicationContext; +import com.oblador.keychain.cipherStorage.CipherStorage; import com.oblador.keychain.cipherStorage.CipherStorage.EncryptionResult; import com.oblador.keychain.cipherStorage.CipherStorageFacebookConceal; +@SuppressWarnings({"unused", "WeakerAccess"}) public class PrefsStorage { - public static final String KEYCHAIN_DATA = "RN_KEYCHAIN"; - - static public class ResultSet { - public final String cipherStorageName; - public final byte[] usernameBytes; - public final byte[] passwordBytes; - - public ResultSet(String cipherStorageName, byte[] usernameBytes, byte[] passwordBytes) { - this.cipherStorageName = cipherStorageName; - this.usernameBytes = usernameBytes; - this.passwordBytes = passwordBytes; - } - } + public static final String KEYCHAIN_DATA = "RN_KEYCHAIN"; - private final SharedPreferences prefs; + static public class ResultSet extends CipherStorage.CipherResult { + public final String cipherStorageName; - public PrefsStorage(ReactApplicationContext reactContext) { - this.prefs = reactContext.getSharedPreferences(KEYCHAIN_DATA, Context.MODE_PRIVATE); - } + public ResultSet(final String cipherStorageName, final byte[] usernameBytes, final byte[] passwordBytes) { + super(usernameBytes, passwordBytes); - public ResultSet getEncryptedEntry(@NonNull String service) { - byte[] bytesForUsername = getBytesForUsername(service); - byte[] bytesForPassword = getBytesForPassword(service); - String cipherStorageName = getCipherStorageName(service); - if (bytesForUsername != null && bytesForPassword != null) { - if (cipherStorageName == null) { - // If the CipherStorage name is not found, we assume it is because the entry was written by an older version of this library. The older version used Facebook Conceal, so we default to that. - cipherStorageName = CipherStorageFacebookConceal.CIPHER_STORAGE_NAME; - } - return new ResultSet(cipherStorageName, bytesForUsername, bytesForPassword); - } - return null; + this.cipherStorageName = cipherStorageName; } + } - public void removeEntry(@NonNull String service) { - String keyForUsername = getKeyForUsername(service); - String keyForPassword = getKeyForPassword(service); - String keyForCipherStorage = getKeyForCipherStorage(service); - - prefs.edit() - .remove(keyForUsername) - .remove(keyForPassword) - .remove(keyForCipherStorage).apply(); - } + @NonNull + private final SharedPreferences prefs; - public void storeEncryptedEntry(@NonNull String service, @NonNull EncryptionResult encryptionResult) { - String keyForUsername = getKeyForUsername(service); - String keyForPassword = getKeyForPassword(service); - String keyForCipherStorage = getKeyForCipherStorage(service); + public PrefsStorage(@NonNull final ReactApplicationContext reactContext) { + this.prefs = reactContext.getSharedPreferences(KEYCHAIN_DATA, Context.MODE_PRIVATE); + } - prefs.edit() - .putString(keyForUsername, Base64.encodeToString(encryptionResult.username, Base64.DEFAULT)) - .putString(keyForPassword, Base64.encodeToString(encryptionResult.password, Base64.DEFAULT)) - .putString(keyForCipherStorage, encryptionResult.cipherStorage.getCipherStorageName()) - .apply(); - } + @Nullable + public ResultSet getEncryptedEntry(@NonNull final String service) { + byte[] bytesForUsername = getBytesForUsername(service); + byte[] bytesForPassword = getBytesForPassword(service); + String cipherStorageName = getCipherStorageName(service); - private byte[] getBytesForUsername(String service) { - String key = getKeyForUsername(service); - return getBytes(key); + // in case of wrong password or username + if (bytesForUsername == null || bytesForPassword == null) { + return null; } - private byte[] getBytesForPassword(String service) { - String key = getKeyForPassword(service); - return getBytes(key); + if (cipherStorageName == null) { + // If the CipherStorage name is not found, we assume it is because the entry was written by an older version of this library. The older version used Facebook Conceal, so we default to that. + cipherStorageName = CipherStorageFacebookConceal.CIPHER_STORAGE_NAME_FACEBOOK; } - private String getCipherStorageName(String service) { - String key = getKeyForCipherStorage(service); - return this.prefs.getString(key, null); + return new ResultSet(cipherStorageName, bytesForUsername, bytesForPassword); + + } + + public void removeEntry(@NonNull final String service) { + final String keyForUsername = getKeyForUsername(service); + final String keyForPassword = getKeyForPassword(service); + final String keyForCipherStorage = getKeyForCipherStorage(service); + + prefs.edit() + .remove(keyForUsername) + .remove(keyForPassword) + .remove(keyForCipherStorage) + .apply(); + } + + public void storeEncryptedEntry(@NonNull final String service, @NonNull final EncryptionResult encryptionResult) { + final String keyForUsername = getKeyForUsername(service); + final String keyForPassword = getKeyForPassword(service); + final String keyForCipherStorage = getKeyForCipherStorage(service); + + prefs.edit() + .putString(keyForUsername, Base64.encodeToString(encryptionResult.username, Base64.DEFAULT)) + .putString(keyForPassword, Base64.encodeToString(encryptionResult.password, Base64.DEFAULT)) + .putString(keyForCipherStorage, encryptionResult.cipherStorage.getCipherStorageName()) + .apply(); + } + + @Nullable + private byte[] getBytesForUsername(@NonNull final String service) { + final String key = getKeyForUsername(service); + + return getBytes(key); + } + + @Nullable + private byte[] getBytesForPassword(@NonNull final String service) { + String key = getKeyForPassword(service); + return getBytes(key); + } + + @Nullable + private String getCipherStorageName(@NonNull final String service) { + String key = getKeyForCipherStorage(service); + + return this.prefs.getString(key, null); + } + + @NonNull + public static String getKeyForUsername(@NonNull final String service) { + return service + ":" + "u"; + } + + @NonNull + public static String getKeyForPassword(@NonNull final String service) { + return service + ":" + "p"; + } + + @NonNull + public static String getKeyForCipherStorage(@NonNull final String service) { + return service + ":" + "c"; + } + + @Nullable + private byte[] getBytes(@NonNull final String key) { + String value = this.prefs.getString(key, null); + + if (value != null) { + return Base64.decode(value, Base64.DEFAULT); } - private String getKeyForUsername(String service) { - return service + ":" + "u"; - } - - private String getKeyForPassword(String service) { - return service + ":" + "p"; - } - - private String getKeyForCipherStorage(String service) { - return service + ":" + "c"; - } - - private byte[] getBytes(String key) { - String value = this.prefs.getString(key, null); - if (value != null) { - return Base64.decode(value, Base64.DEFAULT); - } - return null; - } + return null; + } } diff --git a/android/src/main/java/com/oblador/keychain/SecurityLevel.java b/android/src/main/java/com/oblador/keychain/SecurityLevel.java index 22c5c804..9a0b370b 100644 --- a/android/src/main/java/com/oblador/keychain/SecurityLevel.java +++ b/android/src/main/java/com/oblador/keychain/SecurityLevel.java @@ -1,16 +1,24 @@ package com.oblador.keychain; +import androidx.annotation.NonNull; + +/** */ public enum SecurityLevel { - ANY, - SECURE_SOFTWARE, - SECURE_HARDWARE; // Trusted Execution Environment or Secure Environment guarantees + /** */ + ANY, + /** */ + SECURE_SOFTWARE, + /** Trusted Execution Environment or Secure Environment guarantees */ + SECURE_HARDWARE; - public String jsName() { - return String.format("SECURITY_LEVEL_%s", this.name()); - } + /** Get JavaScript friendly name. */ + @NonNull + public String jsName() { + return String.format("SECURITY_LEVEL_%s", this.name()); + } - public boolean satisfiesSafetyThreshold(SecurityLevel threshold) { - return this.compareTo(threshold) >= 0; - } + public boolean satisfiesSafetyThreshold(@NonNull final SecurityLevel threshold) { + return this.compareTo(threshold) >= 0; + } } diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorage.java b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorage.java index 7557ce16..7765db77 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorage.java +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorage.java @@ -1,55 +1,156 @@ package com.oblador.keychain.cipherStorage; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.oblador.keychain.SecurityLevel; import com.oblador.keychain.exceptions.CryptoFailedException; import com.oblador.keychain.exceptions.KeyStoreAccessException; +import java.security.Key; + +@SuppressWarnings({"unused", "WeakerAccess"}) public interface CipherStorage { - abstract class CipherResult { - public final T username; - public final T password; - - public CipherResult(T username, T password) { - this.username = username; - this.password = password; - } - } + //region Helper classes - class EncryptionResult extends CipherResult { - public CipherStorage cipherStorage; + /** basis for storing credentials in different data type formats. */ + abstract class CipherResult { + public final T username; + public final T password; - public EncryptionResult(byte[] username, byte[] password, CipherStorage cipherStorage) { - super(username, password); - this.cipherStorage = cipherStorage; - } + public CipherResult(T username, T password) { + this.username = username; + this.password = password; } + } - class DecryptionResult extends CipherResult { - private SecurityLevel securityLevel; - - public DecryptionResult(String username, String password, SecurityLevel level) { - super(username, password); - securityLevel = level; - } + /** Credentials in bytes array, often a result of encryption. */ + class EncryptionResult extends CipherResult { + public final CipherStorage cipherStorage; - public SecurityLevel getSecurityLevel() { - return securityLevel; - } + public EncryptionResult(final byte[] username, final byte[] password, final CipherStorage cipherStorage) { + super(username, password); + this.cipherStorage = cipherStorage; } + } - EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password, SecurityLevel level) throws CryptoFailedException; - - DecryptionResult decrypt(@NonNull String service, @NonNull byte[] username, @NonNull byte[] password) throws CryptoFailedException; - - void removeKey(@NonNull String service) throws KeyStoreAccessException; + /** Credentials in string's, often a result of decryption. */ + class DecryptionResult extends CipherResult { + private final SecurityLevel securityLevel; - String getCipherStorageName(); - - int getMinSupportedApiLevel(); + public DecryptionResult(final String username, final String password) { + this(username, password, SecurityLevel.ANY); + } - SecurityLevel securityLevel(); + public DecryptionResult(final String username, final String password, final SecurityLevel level) { + super(username, password); + securityLevel = level; + } - boolean supportsSecureHardware(); + public SecurityLevel getSecurityLevel() { + return securityLevel; + } + } + + /** Ask access permission for decrypting credentials in provided context. */ + class DecryptionContext extends CipherResult { + public final Key key; + public final String keyAlias; + + public DecryptionContext(@NonNull final String keyAlias, + @NonNull final Key key, + @NonNull final byte[] password, + @NonNull final byte[] username) { + super(username, password); + this.keyAlias = keyAlias; + this.key = key; + } + } + + /** Get access to the results of decryption via properties. */ + interface WithResults { + /** Get reference on results. */ + @Nullable + DecryptionResult getResult(); + + /** Get reference on capture error. */ + @Nullable + Throwable getError(); + + /** Block thread and wait for any result of execution. */ + void waitResult(); + } + + /** Handler that allows to inject some actions during decrypt operations. */ + interface DecryptionResultHandler extends WithResults { + /** Ask user for interaction, often its unlock of keystore by biometric data providing. */ + void askAccessPermissions(@NonNull final DecryptionContext context); + + /** + * + */ + void onDecrypt(@Nullable final DecryptionResult decryptionResult, @Nullable final Throwable error); + } + //endregion + + //region API + + /** Encrypt credentials with provided key (by alias) and required security level. */ + @NonNull + EncryptionResult encrypt(@NonNull final String alias, + @NonNull final String username, + @NonNull final String password, + @NonNull final SecurityLevel level) + throws CryptoFailedException; + + /** + * Decrypt credentials with provided key (by alias) and required security level. + * In case of key stored in weaker security level than required will be raised exception. + * That can happens during migration from one version of library to another. + */ + @NonNull + DecryptionResult decrypt(@NonNull final String alias, + @NonNull final byte[] username, + @NonNull final byte[] password, + @NonNull final SecurityLevel level) + throws CryptoFailedException; + + /** Decrypt the credentials but redirect results of operation to handler. */ + void decrypt(@NonNull final DecryptionResultHandler handler, + @NonNull final String alias, + @NonNull final byte[] username, + @NonNull final byte[] password, + @NonNull final SecurityLevel level) + throws CryptoFailedException; + + /** Remove key (by alias) from storage. */ + void removeKey(@NonNull final String alias) throws KeyStoreAccessException; + //endregion + + //region Configuration + + /** Storage name. */ + String getCipherStorageName(); + + /** Minimal API level needed for using the storage. */ + int getMinSupportedApiLevel(); + + /** Provided security level. */ + SecurityLevel securityLevel(); + + /** True - based on secured hardware capabilities, otherwise False. */ + boolean supportsSecureHardware(); + + /** True - based on biometric capabilities, otherwise false. */ + boolean isBiometrySupported(); + + /** + * The higher value means better capabilities. + * Formula: + * = 1000 * isBiometrySupported() + + * 100 * isSecureHardware() + + * minSupportedApiLevel() + */ + int getCapabilityLevel(); + //endregion } diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.java b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.java new file mode 100644 index 00000000..36ddadba --- /dev/null +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.java @@ -0,0 +1,489 @@ +package com.oblador.keychain.cipherStorage; + +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyInfo; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.oblador.keychain.SecurityLevel; +import com.oblador.keychain.exceptions.CryptoFailedException; +import com.oblador.keychain.exceptions.KeyStoreAccessException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyStore; +import java.security.ProviderException; +import java.security.UnrecoverableKeyException; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.spec.IvParameterSpec; + +import static com.oblador.keychain.SecurityLevel.SECURE_HARDWARE; + +@SuppressWarnings({"unused", "WeakerAccess"}) +abstract public class CipherStorageBase implements CipherStorage { + //region Constants + /** Logging tag. */ + protected static final String LOG_TAG = CipherStorageBase.class.getSimpleName(); + /** Default key storage type/name. */ + public static final String KEYSTORE_TYPE = "AndroidKeyStore"; + /** Key used for testing storage capabilities. */ + public static final String TEST_KEY_ALIAS = KEYSTORE_TYPE + "#supportsSecureHardware"; + /** Default service name. */ + public static final String DEFAULT_ALIAS = "RN_KEYCHAIN_DEFAULT_ALIAS"; + /** Size of hash calculation buffer. Default: 4Kb. */ + private final static int BUFFER_SIZE = 4 * 1024; + /** Default size of read/write operation buffer. Default: 16Kb. */ + private final static int BUFFER_READ_WRITE_SIZE = 4 * BUFFER_SIZE; + /** Default charset encoding. */ + public static final Charset UTF8 = Charset.forName("UTF-8"); + //endregion + + //region Overrides + + /** Hardware supports keystore operations. */ + @Override + public SecurityLevel securityLevel() { + return SecurityLevel.SECURE_HARDWARE; + } + + /** + * The higher value means better capabilities. Range: [19..1129]. + * Formula: `1000 * isBiometrySupported() + 100 * isSecureHardware() + minSupportedApiLevel()` + */ + @Override + public final int getCapabilityLevel() { + // max: 1000 + 100 + 29 == 1129 + // min: 0000 + 000 + 19 == 0019 + + return + (1000 * (isBiometrySupported() ? 1 : 0)) + // 0..1000 + (100 * (supportsSecureHardware() ? 1 : 0)) + // 0..100 + (getMinSupportedApiLevel()); // 19..29 + } + + /** Try device capabilities by creating temporary key in keystore. */ + @Override + public boolean supportsSecureHardware() { + try (SelfDestroyKey sdk = new SelfDestroyKey(TEST_KEY_ALIAS)) { + return validateKeySecurityLevel(SECURE_HARDWARE, sdk.key); + } catch (Throwable ignored) { + return false; + } + } + + /** Remove key with provided name from security storage. */ + @Override + public void removeKey(@NonNull final String alias) throws KeyStoreAccessException { + final String safeService = getDefaultAliasIfEmpty(alias); + final KeyStore ks = getKeyStoreAndLoad(); + + try { + if (ks.containsAlias(safeService)) { + ks.deleteEntry(safeService); + } + } catch (GeneralSecurityException ignored) { + /* only one exception can be raised by code: 'KeyStore is not loaded' */ + } + } + + //endregion + + //region Abstract methods + + /** Get encryption algorithm specification builder instance. */ + @NonNull + protected abstract KeyGenParameterSpec.Builder getKeyGenSpecBuilder(@NonNull final String alias) throws GeneralSecurityException; + + /** Get information about provided key. */ + @NonNull + protected abstract KeyInfo getKeyInfo(@NonNull final Key key) throws GeneralSecurityException; + + /** Try to generate key from provided specification. */ + @NonNull + protected abstract Key generateKey(@NonNull final KeyGenParameterSpec spec) throws GeneralSecurityException; + + /** Get name of the required encryption algorithm. */ + @NonNull + protected abstract String getEncryptionAlgorithm(); + + /** Get transformation algorithm for encrypt/decrypt operations. */ + @NonNull + protected abstract String getEncryptionTransformation(); + //endregion + + //region Implementation + + /** Check requirements to the security level. */ + protected void throwIfInsufficientLevel(@NonNull final SecurityLevel level) + throws CryptoFailedException { + + if (!securityLevel().satisfiesSafetyThreshold(level)) { + throw new CryptoFailedException(String.format( + "Insufficient security level (wants %s; got %s)", + level, securityLevel())); + } + } + + /** Extract existing key or generate a new one. In case of problems raise exception. */ + @NonNull + protected Key extractGeneratedKey(@NonNull final String safeAlias, + @NonNull final SecurityLevel level, + @NonNull final AtomicInteger retries) + throws GeneralSecurityException { + Key key; + + do { + final KeyStore keyStore = getKeyStoreAndLoad(); + + // if key is not available yet, try to generate the strongest possible + if (!keyStore.containsAlias(safeAlias)) { + generateKeyAndStoreUnderAlias(safeAlias, level); + } + + // throw exception if cannot extract key in several retries + key = extractKey(keyStore, safeAlias, retries); + } while (null == key); + + return key; + } + + /** Try to extract key by alias from keystore, in case of 'known android bug' reduce retry counter. */ + @Nullable + protected Key extractKey(@NonNull final KeyStore keyStore, + @NonNull final String safeAlias, + @NonNull final AtomicInteger retry) + throws GeneralSecurityException { + final Key key; + + // Fix for android.security.KeyStoreException: Invalid key blob + // more info: https://stackoverflow.com/questions/36488219/android-security-keystoreexception-invalid-key-blob/36846085#36846085 + try { + key = keyStore.getKey(safeAlias, null); + } catch (final UnrecoverableKeyException ex) { + // try one more time + if (retry.getAndDecrement() > 0) { + keyStore.deleteEntry(safeAlias); + + return null; + } + + throw ex; + } + + // null if the given alias does not exist or does not identify a key-related entry. + if (null == key) { + throw new KeyStoreAccessException("Empty key extracted!"); + } + + return key; + } + + /** Verify that provided key satisfy minimal needed level. */ + protected boolean validateKeySecurityLevel(@NonNull final SecurityLevel level, + @NonNull final Key key) + throws GeneralSecurityException { + + return getSecurityLevel(key) + .satisfiesSafetyThreshold(level); + } + + /** Get the supported level of security for provided Key instance. */ + @NonNull + protected SecurityLevel getSecurityLevel(@NonNull final Key key) throws GeneralSecurityException { + final KeyInfo keyInfo = getKeyInfo(key); + + // lower API23 we don't have any hardware support + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (keyInfo.isInsideSecureHardware()) { + return SECURE_HARDWARE; + } + } + + return SecurityLevel.SECURE_SOFTWARE; + } + + /** Load key store. */ + @NonNull + protected KeyStore getKeyStoreAndLoad() throws KeyStoreAccessException { + try { + final KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE); + + // initialize instance + keyStore.load(null); + + return keyStore; + } catch (final Throwable fail) { + throw new KeyStoreAccessException("Could not access Keystore", fail); + } + } + + /** Default encryption with cipher without initialization vector. */ + @NonNull + public byte[] encryptString(@NonNull final Key key, @NonNull final String value) + throws IOException, GeneralSecurityException { + + return encryptString(key, value, Defaults.encrypt); + } + + /** Default decryption with cipher without initialization vector. */ + @NonNull + public String decryptBytes(@NonNull final Key key, @NonNull final byte[] bytes) + throws IOException, GeneralSecurityException { + + return decryptBytes(key, bytes, Defaults.decrypt); + } + + /** Encrypt provided string value. */ + @NonNull + protected byte[] encryptString(@NonNull final Key key, @NonNull final String value, + @Nullable final EncryptStringHandler handler) + throws IOException, GeneralSecurityException { + + final Cipher cipher = Cipher.getInstance(getEncryptionTransformation()); + + // encrypt the value using a CipherOutputStream + try (final ByteArrayOutputStream output = new ByteArrayOutputStream()) { + + // write initialization vector to the beginning of the stream + if (null != handler) { + handler.initialize(cipher, key, output); + output.flush(); + } + + try (final CipherOutputStream encrypt = new CipherOutputStream(output, cipher)) { + encrypt.write(value.getBytes(UTF8)); + } + + return output.toByteArray(); + } catch (Throwable fail) { + Log.e(LOG_TAG, fail.getMessage(), fail); + + throw fail; + } + } + + /** Decrypt provided bytes to a string. */ + @NonNull + protected String decryptBytes(@NonNull final Key key, @NonNull final byte[] bytes, + @Nullable final DecryptBytesHandler handler) + throws GeneralSecurityException, IOException { + final Cipher cipher = Cipher.getInstance(getEncryptionTransformation()); + + // decrypt the bytes using a CipherInputStream + try (ByteArrayInputStream in = new ByteArrayInputStream(bytes); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + + // read the initialization vector from the beginning of the stream + if (null != handler) { + handler.initialize(cipher, key, in); + } + + try (CipherInputStream decrypt = new CipherInputStream(in, cipher)) { + copy(decrypt, output); + } + + return new String(output.toByteArray(), UTF8); + } catch (Throwable fail) { + Log.w(LOG_TAG, fail.getMessage(), fail); + + throw fail; + } + } + + /** Get the most secured keystore */ + protected void generateKeyAndStoreUnderAlias(@NonNull final String alias, + @NonNull final SecurityLevel requiredLevel) + throws GeneralSecurityException { + + // Firstly, try to generate the key as safe as possible (strongbox). + // see https://developer.android.com/training/articles/keystore#HardwareSecurityModule + + Key secretKey; + + try { + secretKey = tryGenerateStrongBoxSecurityKey(alias); + } catch (GeneralSecurityException | ProviderException ex) { + Log.w(LOG_TAG, "StrongBox security storage is not available.", ex); + + // If that is not possible, we generate the key in a regular way + // (it still might be generated in hardware, but not in StrongBox) + try { + secretKey = tryGenerateRegularSecurityKey(alias); + } catch (GeneralSecurityException fail) { + Log.e(LOG_TAG, "Regular security storage is not available.", fail); + throw fail; + } + } + + if (!validateKeySecurityLevel(requiredLevel, secretKey)) { + throw new CryptoFailedException("Cannot generate keys with required security guarantees"); + } + } + + /** Try to get secured keystore instance. */ + @NonNull + protected Key tryGenerateRegularSecurityKey(@NonNull final String alias) + throws GeneralSecurityException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new KeyStoreAccessException("Regular security keystore is not supported " + + "for old API" + Build.VERSION.SDK_INT + "."); + } + + final KeyGenParameterSpec specification = getKeyGenSpecBuilder(alias) + .build(); + + return generateKey(specification); + } + + /** Try to get strong secured keystore instance. (StrongBox security chip) */ + @NonNull + protected Key tryGenerateStrongBoxSecurityKey(@NonNull final String alias) + throws GeneralSecurityException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + throw new KeyStoreAccessException("Strong box security keystore is not supported " + + "for old API" + Build.VERSION.SDK_INT + "."); + } + + final KeyGenParameterSpec specification = getKeyGenSpecBuilder(alias) + .setIsStrongBoxBacked(true) + .build(); + + return generateKey(specification); + } + + //endregion + + //region Static methods + + /** Convert provided service name to safe not-null/not-empty value. */ + @NonNull + public static String getDefaultAliasIfEmpty(@Nullable final String service) { + //noinspection ConstantConditions + return TextUtils.isEmpty(service) ? DEFAULT_ALIAS : service; + } + + /** + * Copy input stream to output. + * + * @param in instance of input stream. + * @param out instance of output stream. + * @throws IOException read/write operation failure. + */ + public static void copy(@NonNull final InputStream in, @NonNull final OutputStream out) throws IOException { + // Transfer bytes from in to out + final byte[] buf = new byte[BUFFER_READ_WRITE_SIZE]; + int len; + + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } + //endregion + + //region Nested declarations + /** Generic cipher initialization. */ + public static final class Defaults { + public static final EncryptStringHandler encrypt = (cipher, key, output) -> { + cipher.init(Cipher.ENCRYPT_MODE, key); + }; + + public static final DecryptBytesHandler decrypt = (cipher, key, input) -> { + cipher.init(Cipher.DECRYPT_MODE, key); + }; + } + + /** Initialization vector support. */ + public static final class IV { + /** Encryption/Decryption initialization vector length. */ + public static final int IV_LENGTH = 16; + + /** Save Initialization vector to output stream. */ + public static final EncryptStringHandler encrypt = (cipher, key, output) -> { + cipher.init(Cipher.ENCRYPT_MODE, key); + + final byte[] iv = cipher.getIV(); + output.write(iv, 0, iv.length); + }; + /** Read initialization vector from input stream and configure cipher by it. */ + public static final DecryptBytesHandler decrypt = (cipher, key, input) -> { + final IvParameterSpec iv = readIv(input); + cipher.init(Cipher.DECRYPT_MODE, key, iv); + }; + + /** Extract initialization vector from provided bytes array. */ + @NonNull + public static IvParameterSpec readIv(@NonNull final byte[] bytes) throws IOException { + final byte[] iv = new byte[IV_LENGTH]; + + if (IV_LENGTH <= bytes.length) + throw new IOException("Insufficient length of input data for IV extracting."); + + System.arraycopy(bytes, 0, iv, 0, IV_LENGTH); + + return new IvParameterSpec(iv); + } + + /** Extract initialization vector from provided input stream. */ + @NonNull + public static IvParameterSpec readIv(@NonNull final InputStream inputStream) throws IOException { + final byte[] iv = new byte[IV_LENGTH]; + final int result = inputStream.read(iv, 0, IV_LENGTH); + + if (result != IV_LENGTH) + throw new IOException("Input stream has insufficient data."); + + return new IvParameterSpec(iv); + } + } + + /** Handler for storing cipher configuration in output stream. */ + public interface EncryptStringHandler { + void initialize(@NonNull final Cipher cipher, @NonNull final Key key, @NonNull final OutputStream output) + throws GeneralSecurityException, IOException; + } + + /** Handler for configuring cipher by initialization data from input stream. */ + public interface DecryptBytesHandler { + void initialize(@NonNull final Cipher cipher, @NonNull final Key key, @NonNull final InputStream input) + throws GeneralSecurityException, IOException; + } + + /** Auto remove keystore key. */ + public class SelfDestroyKey implements AutoCloseable { + public final String name; + public final Key key; + + public SelfDestroyKey(@NonNull final String name) throws GeneralSecurityException { + this(name, tryGenerateRegularSecurityKey(name)); + } + + public SelfDestroyKey(@NonNull final String name, @NonNull final Key key) { + this.name = name; + this.key = key; + } + + @Override + public void close() { + try { + removeKey(name); + } catch (KeyStoreAccessException ex) { + Log.w(LOG_TAG, "AutoClose remove key failed. Error: " + ex.getMessage(), ex); + } + } + } + //endregion +} diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageFacebookConceal.java b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageFacebookConceal.java index 3162f994..02beb446 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageFacebookConceal.java +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageFacebookConceal.java @@ -1,6 +1,10 @@ package com.oblador.keychain.cipherStorage; import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyInfo; +import android.util.Log; + import androidx.annotation.NonNull; import com.facebook.android.crypto.keychain.AndroidConceal; @@ -9,102 +13,194 @@ import com.facebook.crypto.CryptoConfig; import com.facebook.crypto.Entity; import com.facebook.crypto.keychain.KeyChain; +import com.facebook.react.bridge.AssertionException; import com.facebook.react.bridge.ReactApplicationContext; import com.oblador.keychain.SecurityLevel; import com.oblador.keychain.exceptions.CryptoFailedException; -import java.nio.charset.Charset; - -public class CipherStorageFacebookConceal implements CipherStorage { - public static final String CIPHER_STORAGE_NAME = "FacebookConceal"; - public static final String KEYCHAIN_DATA = "RN_KEYCHAIN"; - private final Crypto crypto; - - public CipherStorageFacebookConceal(ReactApplicationContext reactContext) { - KeyChain keyChain = new SharedPrefsBackedKeyChain(reactContext, CryptoConfig.KEY_256); - this.crypto = AndroidConceal.get().createDefaultCrypto(keyChain); - } - - @Override - public String getCipherStorageName() { - return CIPHER_STORAGE_NAME; +import java.security.GeneralSecurityException; +import java.security.Key; + +/** + * @see Conceal Project + * @see Fast Cryptographics + */ +@SuppressWarnings({"unused", "WeakerAccess"}) +public class CipherStorageFacebookConceal extends CipherStorageBase { + public static final String CIPHER_STORAGE_NAME_FACEBOOK = "FacebookConceal"; + public static final String KEYCHAIN_DATA = "RN_KEYCHAIN"; + + private final Crypto crypto; + + public CipherStorageFacebookConceal(@NonNull final ReactApplicationContext reactContext) { + KeyChain keyChain = new SharedPrefsBackedKeyChain(reactContext, CryptoConfig.KEY_256); + + this.crypto = AndroidConceal.get().createDefaultCrypto(keyChain); + } + + //region Configuration + @Override + public String getCipherStorageName() { + return CIPHER_STORAGE_NAME_FACEBOOK; + } + + @Override + public int getMinSupportedApiLevel() { + return Build.VERSION_CODES.JELLY_BEAN; + } + + @Override + public SecurityLevel securityLevel() { + return SecurityLevel.ANY; + } + + @Override + public boolean supportsSecureHardware() { + return false; + } + + @Override + public boolean isBiometrySupported() { + return false; + } + //endregion + + //region Overrides + @Override + @NonNull + public EncryptionResult encrypt(@NonNull final String alias, + @NonNull final String username, + @NonNull final String password, + @NonNull final SecurityLevel level) + throws CryptoFailedException { + + throwIfInsufficientLevel(level); + throwIfNoCryptoAvailable(); + + final Entity usernameEntity = createUsernameEntity(alias); + final Entity passwordEntity = createPasswordEntity(alias); + + try { + final byte[] encryptedUsername = crypto.encrypt(username.getBytes(UTF8), usernameEntity); + final byte[] encryptedPassword = crypto.encrypt(password.getBytes(UTF8), passwordEntity); + + return new EncryptionResult( + encryptedUsername, + encryptedPassword, + this); + } catch (Throwable fail) { + throw new CryptoFailedException("Encryption failed for alias: " + alias, fail); } - - @Override - public int getMinSupportedApiLevel() { - return Build.VERSION_CODES.JELLY_BEAN; + } + + @NonNull + @Override + public DecryptionResult decrypt(@NonNull final String alias, + @NonNull final byte[] username, + @NonNull final byte[] password, + @NonNull final SecurityLevel level) + throws CryptoFailedException { + + throwIfInsufficientLevel(level); + throwIfNoCryptoAvailable(); + + final Entity usernameEntity = createUsernameEntity(alias); + final Entity passwordEntity = createPasswordEntity(alias); + + try { + final byte[] decryptedUsername = crypto.decrypt(username, usernameEntity); + final byte[] decryptedPassword = crypto.decrypt(password, passwordEntity); + + return new DecryptionResult( + new String(decryptedUsername, UTF8), + new String(decryptedPassword, UTF8), + SecurityLevel.ANY); + } catch (Throwable fail) { + throw new CryptoFailedException("Decryption failed for alias: " + alias, fail); } - - @Override - public SecurityLevel securityLevel() { - return SecurityLevel.ANY; + } + + /** redirect call to default {@link #decrypt(String, byte[], byte[], SecurityLevel)} method. */ + @Override + public void decrypt(@NonNull DecryptionResultHandler handler, + @NonNull String service, + @NonNull byte[] username, + @NonNull byte[] password, + @NonNull final SecurityLevel level) { + + try { + final DecryptionResult results = decrypt(service, username, password, level); + + handler.onDecrypt(results, null); + } catch (Throwable fail) { + handler.onDecrypt(null, fail); } - - @Override - public boolean supportsSecureHardware() { - return false; + } + + @Override + public void removeKey(@NonNull final String alias) { + // Facebook Conceal stores only one key across all services, so we cannot + // delete the key (otherwise decryption will fail for encrypted data of other services). + Log.w(LOG_TAG, "CipherStorageFacebookConceal removeKey called. alias: " + alias); + } + + @NonNull + @Override + protected KeyGenParameterSpec.Builder getKeyGenSpecBuilder(@NonNull final String alias) + throws GeneralSecurityException { + throw new CryptoFailedException("Not designed for a call"); + } + + @NonNull + @Override + protected KeyInfo getKeyInfo(@NonNull final Key key) throws GeneralSecurityException { + throw new CryptoFailedException("Not designed for a call"); + } + + @NonNull + @Override + protected Key generateKey(@NonNull final KeyGenParameterSpec spec) throws GeneralSecurityException { + throw new CryptoFailedException("Not designed for a call"); + } + + @NonNull + @Override + protected String getEncryptionAlgorithm() { + throw new AssertionException("Not designed for a call"); + } + + @NonNull + @Override + protected String getEncryptionTransformation() { + throw new AssertionException("Not designed for a call"); + } + + /** Verify availability of the Crypto API. */ + private void throwIfNoCryptoAvailable() throws CryptoFailedException { + if (!crypto.isAvailable()) { + throw new CryptoFailedException("Crypto is missing"); } + } + //endregion - @Override - public EncryptionResult encrypt(@NonNull String service, @NonNull String username, @NonNull String password, SecurityLevel level) throws CryptoFailedException { + //region Helper methods + @NonNull + private static Entity createUsernameEntity(@NonNull final String alias) { + final String prefix = getEntityPrefix(alias); - if (!this.securityLevel().satisfiesSafetyThreshold(level)) { - throw new CryptoFailedException(String.format("Insufficient security level (wants %s; got %s)", level, this.securityLevel())); - } + return Entity.create(prefix + "user"); + } - if (!crypto.isAvailable()) { - throw new CryptoFailedException("Crypto is missing"); - } - Entity usernameEntity = createUsernameEntity(service); - Entity passwordEntity = createPasswordEntity(service); + @NonNull + private static Entity createPasswordEntity(@NonNull final String alias) { + final String prefix = getEntityPrefix(alias); - try { - byte[] encryptedUsername = crypto.encrypt(username.getBytes(Charset.forName("UTF-8")), usernameEntity); - byte[] encryptedPassword = crypto.encrypt(password.getBytes(Charset.forName("UTF-8")), passwordEntity); - - return new EncryptionResult(encryptedUsername, encryptedPassword, this); - } catch (Exception e) { - throw new CryptoFailedException("Encryption failed for service " + service, e); - } - } + return Entity.create(prefix + "pass"); + } - @Override - public DecryptionResult decrypt(@NonNull String service, @NonNull byte[] username, @NonNull byte[] password) throws CryptoFailedException { - if (!crypto.isAvailable()) { - throw new CryptoFailedException("Crypto is missing"); - } - Entity usernameEntity = createUsernameEntity(service); - Entity passwordEntity = createPasswordEntity(service); - - try { - byte[] decryptedUsername = crypto.decrypt(username, usernameEntity); - byte[] decryptedPassword = crypto.decrypt(password, passwordEntity); - - return new DecryptionResult( - new String(decryptedUsername, Charset.forName("UTF-8")), - new String(decryptedPassword, Charset.forName("UTF-8")), - SecurityLevel.ANY); - } catch (Exception e) { - throw new CryptoFailedException("Decryption failed for service " + service, e); - } - } - - @Override - public void removeKey(@NonNull String service) { - // Facebook Conceal stores only one key across all services, so we cannot delete the key (otherwise decryption will fail for encrypted data of other services). - } - - private Entity createUsernameEntity(String service) { - String prefix = getEntityPrefix(service); - return Entity.create(prefix + "user"); - } - - private Entity createPasswordEntity(String service) { - String prefix = getEntityPrefix(service); - return Entity.create(prefix + "pass"); - } - - private String getEntityPrefix(String service) { - return KEYCHAIN_DATA + ":" + service; - } + @NonNull + private static String getEntityPrefix(@NonNull final String alias) { + return KEYCHAIN_DATA + ":" + alias; + } + //endregion } diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAESCBC.java b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAESCBC.java~fix: remove key if failed to generate with sufficient security level (#218) similarity index 92% rename from android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAESCBC.java rename to android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAESCBC.java~fix: remove key if failed to generate with sufficient security level (#218) index d7c9ab5a..a1100183 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAESCBC.java +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAESCBC.java~fix: remove key if failed to generate with sufficient security level (#218) @@ -12,6 +12,7 @@ import com.oblador.keychain.exceptions.CryptoFailedException; import com.oblador.keychain.exceptions.KeyStoreAccessException; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.Charset; @@ -27,6 +28,7 @@ import android.security.keystore.StrongBoxUnavailableException; import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; import javax.crypto.CipherOutputStream; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; @@ -226,19 +228,35 @@ private byte[] encryptString(Key key, String service, String value) throws Crypt private String decryptBytes(Key key, byte[] bytes) throws CryptoFailedException { try { - int ivLength = 16; Cipher cipher = Cipher.getInstance(ENCRYPTION_TRANSFORMATION); + ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes); // read the initialization vector from the beginning of the stream - IvParameterSpec ivParams = new IvParameterSpec(bytes, 0, ivLength); + IvParameterSpec ivParams = readIvFromStream(inputStream); cipher.init(Cipher.DECRYPT_MODE, key, ivParams); - - byte[] decryptedBytes = cipher.doFinal(bytes, ivLength, bytes.length - ivLength); - return new String(decryptedBytes, Charset.forName("UTF-8")); + // decrypt the bytes using a CipherInputStream + CipherInputStream cipherInputStream = new CipherInputStream( + inputStream, cipher); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + while (true) { + int n = cipherInputStream.read(buffer, 0, buffer.length); + if (n <= 0) { + break; + } + output.write(buffer, 0, n); + } + return new String(output.toByteArray(), Charset.forName("UTF-8")); } catch (Exception e) { throw new CryptoFailedException("Could not decrypt bytes: " + e.getMessage(), e); } } + private IvParameterSpec readIvFromStream(ByteArrayInputStream inputStream) { + byte[] iv = new byte[16]; + inputStream.read(iv, 0, iv.length); + return new IvParameterSpec(iv); + } + private KeyStore getKeyStoreAndLoad() throws KeyStoreException, KeyStoreAccessException { try { KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE); diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.java b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.java new file mode 100644 index 00000000..080f8583 --- /dev/null +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.java @@ -0,0 +1,302 @@ +package com.oblador.keychain.cipherStorage; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyInfo; +import android.security.keystore.KeyProperties; +import android.security.keystore.UserNotAuthenticatedException; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.oblador.keychain.SecurityLevel; +import com.oblador.keychain.exceptions.CryptoFailedException; +import com.oblador.keychain.exceptions.KeyStoreAccessException; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.crypto.NoSuchPaddingException; + +/** Fingerprint biometry protected storage. */ +@RequiresApi(api = Build.VERSION_CODES.M) +@SuppressWarnings({"unused", "WeakerAccess"}) +public class CipherStorageKeystoreRsaEcb extends CipherStorageBase { + //region Constants + /** Storage name. */ + public static final String CIPHER_STORAGE_NAME_RSAECB = "KeystoreRSAECB"; + /** Selected algorithm. */ + public static final String ALGORITHM_RSA = KeyProperties.KEY_ALGORITHM_RSA; + /** Selected block mode. */ + public static final String BLOCK_MODE_ECB = KeyProperties.BLOCK_MODE_ECB; + /** Selected padding transformation. */ + public static final String PADDING_PKCS1 = KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1; + /** Composed transformation algorithms. */ + public static final String TRANSFORMATION_RSA_ECB_PKCS1 = + ALGORITHM_RSA + "/" + BLOCK_MODE_ECB + "/" + PADDING_PKCS1; + /** Selected encryption key size. */ + public static final int ENCRYPTION_KEY_SIZE = 3072; + //endregion + + //region Overrides + @Override + @NonNull + public EncryptionResult encrypt(@NonNull final String alias, + @NonNull final String username, + @NonNull final String password, + @NonNull final SecurityLevel level) + throws CryptoFailedException { + + throwIfInsufficientLevel(level); + + final String safeService = getDefaultAliasIfEmpty(alias); + + try { + return innerEncryptedCredentials(safeService, password, username, level); + + // KeyStoreException | KeyStoreAccessException | NoSuchAlgorithmException | InvalidKeySpecException | + // IOException | NoSuchPaddingException | InvalidKeyException e + } catch (NoSuchAlgorithmException | InvalidKeySpecException | NoSuchPaddingException | InvalidKeyException e) { + throw new CryptoFailedException("Could not encrypt data for service " + alias, e); + } catch (KeyStoreException | KeyStoreAccessException e) { + throw new CryptoFailedException("Could not access Keystore for service " + alias, e); + } catch (IOException io) { + throw new CryptoFailedException("I/O error: " + io.getMessage(), io); + } catch (final Throwable ex) { + throw new CryptoFailedException("Unknown error: " + ex.getMessage(), ex); + } + } + + @NonNull + @Override + public DecryptionResult decrypt(@NonNull String alias, + @NonNull byte[] username, + @NonNull byte[] password, + @NonNull final SecurityLevel level) + throws CryptoFailedException { + + final NonInteractiveHandler handler = new NonInteractiveHandler(); + decrypt(handler, alias, username, password, level); + + CryptoFailedException.reThrowOnError(handler.getError()); + + if (null == handler.getResult()) { + throw new CryptoFailedException("No decryption results and no error. Something deeply wrong!"); + } + + return handler.getResult(); + } + + @Override + @SuppressLint("NewApi") + public void decrypt(@NonNull DecryptionResultHandler handler, + @NonNull String alias, + @NonNull byte[] username, + @NonNull byte[] password, + @NonNull final SecurityLevel level) + throws CryptoFailedException { + + throwIfInsufficientLevel(level); + + final String safeAlias = getDefaultAliasIfEmpty(alias); + final AtomicInteger retries = new AtomicInteger(1); + boolean shouldAskPermissions = false; + + Key key = null; + + try { + // key is always NOT NULL otherwise GeneralSecurityException raised + key = extractGeneratedKey(safeAlias, level, retries); + + final DecryptionResult results = new DecryptionResult( + decryptBytes(key, username), + decryptBytes(key, password) + ); + + handler.onDecrypt(results, null); + } catch (final UserNotAuthenticatedException ex) { + Log.d(LOG_TAG, "Unlock of keystore is needed. Error: " + ex.getMessage(), ex); + + // expected that KEY instance is extracted and we caught exception on decryptBytes operation + @SuppressWarnings("ConstantConditions") final DecryptionContext context = + new DecryptionContext(safeAlias, key, password, username); + + handler.askAccessPermissions(context); + } catch (final Throwable fail) { + // any other exception treated as a failure + handler.onDecrypt(null, fail); + } + } + + //endregion + + //region Configuration + + /** RSAECB. */ + @Override + public String getCipherStorageName() { + return CIPHER_STORAGE_NAME_RSAECB; + } + + /** API23 is a requirement. */ + @Override + public int getMinSupportedApiLevel() { + return Build.VERSION_CODES.M; + } + + /** Biometry is supported. */ + @Override + public boolean isBiometrySupported() { + return true; + } + + /** RSA. */ + @NonNull + @Override + protected String getEncryptionAlgorithm() { + return ALGORITHM_RSA; + } + + /** RSA/ECB/PKCS1Padding */ + @NonNull + @Override + protected String getEncryptionTransformation() { + return TRANSFORMATION_RSA_ECB_PKCS1; + } + //endregion + + //region Implementation + + /** Clean code without try/catch's that encrypt username and password with a key specified by alias. */ + @NonNull + private EncryptionResult innerEncryptedCredentials(@NonNull final String alias, + @NonNull final String password, + @NonNull final String username, + @NonNull final SecurityLevel level) + throws GeneralSecurityException, IOException { + + final KeyStore store = getKeyStoreAndLoad(); + + // on first access create a key for storage + if (!store.containsAlias(alias)) { + generateKeyAndStoreUnderAlias(alias, level); + } + + final KeyFactory kf = KeyFactory.getInstance(ALGORITHM_RSA); + final Certificate certificate = store.getCertificate(alias); + final PublicKey publicKey = certificate.getPublicKey(); + final X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKey.getEncoded()); + final PublicKey key = kf.generatePublic(keySpec); + + return new EncryptionResult( + encryptString(key, username), + encryptString(key, password), + this); + } + + /** Get builder for encryption and decryption operations with required user Authentication. */ + @NonNull + @Override + @SuppressLint("NewApi") + protected KeyGenParameterSpec.Builder getKeyGenSpecBuilder(@NonNull final String alias) + throws GeneralSecurityException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new KeyStoreAccessException("Unsupported API" + Build.VERSION.SDK_INT + " version detected."); + } + + final int purposes = KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT; + + return new KeyGenParameterSpec.Builder(alias, purposes) + .setBlockModes(BLOCK_MODE_ECB) + .setEncryptionPaddings(PADDING_PKCS1) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(true) + .setUserAuthenticationValidityDurationSeconds(1) + .setKeySize(ENCRYPTION_KEY_SIZE); + } + + /** Get information about provided key. */ + @NonNull + @Override + protected KeyInfo getKeyInfo(@NonNull final Key key) throws GeneralSecurityException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new KeyStoreAccessException("Unsupported API" + Build.VERSION.SDK_INT + " version detected."); + } + + final KeyFactory factory = KeyFactory.getInstance(key.getAlgorithm(), KEYSTORE_TYPE); + + return factory.getKeySpec(key, KeyInfo.class); + } + + /** Try to generate key from provided specification. */ + @NonNull + @Override + protected Key generateKey(@NonNull final KeyGenParameterSpec spec) throws GeneralSecurityException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw new KeyStoreAccessException("Unsupported API" + Build.VERSION.SDK_INT + " version detected."); + } + + final KeyPairGenerator generator = KeyPairGenerator.getInstance(getEncryptionAlgorithm(), KEYSTORE_TYPE); + generator.initialize(spec); + + return generator.generateKeyPair().getPrivate(); + } + + //endregion + + //region Nested classes + + /** Non interactive handler for decrypting the credentials. */ + public static class NonInteractiveHandler implements DecryptionResultHandler { + private DecryptionResult result; + private Throwable error; + + @Override + public void askAccessPermissions(@NonNull final DecryptionContext context) { + final CryptoFailedException failure = new CryptoFailedException( + "Non interactive decryption mode."); + + onDecrypt(null, failure); + } + + @Override + public void onDecrypt(@Nullable final DecryptionResult decryptionResult, + @Nullable final Throwable error) { + this.result = decryptionResult; + this.error = error; + } + + @Nullable + @Override + public DecryptionResult getResult() { + return result; + } + + @Nullable + @Override + public Throwable getError() { + return error; + } + + @Override + public void waitResult() { + /* do nothing, expected synchronized call in one thread */ + } + } + //endregion +} diff --git a/android/src/main/java/com/oblador/keychain/exceptions/CryptoFailedException.java b/android/src/main/java/com/oblador/keychain/exceptions/CryptoFailedException.java index d522c3ed..cd489e36 100644 --- a/android/src/main/java/com/oblador/keychain/exceptions/CryptoFailedException.java +++ b/android/src/main/java/com/oblador/keychain/exceptions/CryptoFailedException.java @@ -1,11 +1,25 @@ package com.oblador.keychain.exceptions; -public class CryptoFailedException extends Exception { - public CryptoFailedException (String message) { - super(message); - } - - public CryptoFailedException (String message, Throwable t) { - super(message, t); - } +import androidx.annotation.Nullable; + +import java.security.GeneralSecurityException; + +public class CryptoFailedException extends GeneralSecurityException { + public CryptoFailedException(String message) { + super(message); + } + + public CryptoFailedException(String message, Throwable t) { + super(message, t); + } + + public static void reThrowOnError(@Nullable final Throwable error) throws CryptoFailedException { + if(null == error) return; + + if (error instanceof CryptoFailedException) + throw (CryptoFailedException) error; + + throw new CryptoFailedException("Wrapped error: " + error.getMessage(), error); + + } } diff --git a/android/src/main/java/com/oblador/keychain/exceptions/EmptyParameterException.java b/android/src/main/java/com/oblador/keychain/exceptions/EmptyParameterException.java index 574603f9..bb1ded1a 100644 --- a/android/src/main/java/com/oblador/keychain/exceptions/EmptyParameterException.java +++ b/android/src/main/java/com/oblador/keychain/exceptions/EmptyParameterException.java @@ -1,7 +1,7 @@ package com.oblador.keychain.exceptions; public class EmptyParameterException extends Exception { - public EmptyParameterException(String message) { - super(message); - } + public EmptyParameterException(String message) { + super(message); + } } diff --git a/android/src/main/java/com/oblador/keychain/exceptions/KeyStoreAccessException.java b/android/src/main/java/com/oblador/keychain/exceptions/KeyStoreAccessException.java index 74585e1f..c63f3ac4 100644 --- a/android/src/main/java/com/oblador/keychain/exceptions/KeyStoreAccessException.java +++ b/android/src/main/java/com/oblador/keychain/exceptions/KeyStoreAccessException.java @@ -1,7 +1,13 @@ package com.oblador.keychain.exceptions; -public class KeyStoreAccessException extends Exception { - public KeyStoreAccessException(String message, Throwable t) { - super(message, t); - } +import java.security.GeneralSecurityException; + +public class KeyStoreAccessException extends GeneralSecurityException { + public KeyStoreAccessException(final String message) { + super(message); + } + + public KeyStoreAccessException(final String message, final Throwable t) { + super(message, t); + } } diff --git a/android/src/test/java/com/oblador/keychain/FakeKeyFactorySpi.java b/android/src/test/java/com/oblador/keychain/FakeKeyFactorySpi.java new file mode 100644 index 00000000..d0038bdc --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/FakeKeyFactorySpi.java @@ -0,0 +1,15 @@ +package com.oblador.keychain; + +import java.security.Key; +import java.security.KeyFactorySpi; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; + +public abstract class FakeKeyFactorySpi extends KeyFactorySpi { + @Override + protected T engineGetKeySpec(Key key, Class keySpec) throws InvalidKeySpecException { + return doEngineGetKeySpec(key, keySpec); + } + + public abstract T doEngineGetKeySpec(Key key, Class keySpec); +} diff --git a/android/src/test/java/com/oblador/keychain/FakeKeyGeneratorSpi.java b/android/src/test/java/com/oblador/keychain/FakeKeyGeneratorSpi.java new file mode 100644 index 00000000..371f32f1 --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/FakeKeyGeneratorSpi.java @@ -0,0 +1,13 @@ +package com.oblador.keychain; + +import javax.crypto.KeyGeneratorSpi; +import javax.crypto.SecretKey; + +public abstract class FakeKeyGeneratorSpi extends KeyGeneratorSpi { + @Override + protected SecretKey engineGenerateKey() { + return doEngineGenerateKey(); + } + + public abstract SecretKey doEngineGenerateKey(); +} diff --git a/android/src/test/java/com/oblador/keychain/FakeKeystore.java b/android/src/test/java/com/oblador/keychain/FakeKeystore.java new file mode 100644 index 00000000..1a58745a --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/FakeKeystore.java @@ -0,0 +1,97 @@ +package com.oblador.keychain; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.Key; +import java.security.KeyStoreException; +import java.security.KeyStoreSpi; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.util.Date; +import java.util.Enumeration; + +public final class FakeKeystore extends KeyStoreSpi { + + @Override + public Key engineGetKey(String alias, char[] password) throws NoSuchAlgorithmException, UnrecoverableKeyException { + return null; + } + + @Override + public Certificate[] engineGetCertificateChain(String alias) { + return new Certificate[0]; + } + + @Override + public Certificate engineGetCertificate(String alias) { + return null; + } + + @Override + public Date engineGetCreationDate(String alias) { + return null; + } + + @Override + public void engineSetKeyEntry(String alias, Key key, char[] password, Certificate[] chain) throws KeyStoreException { + + } + + @Override + public void engineSetKeyEntry(String alias, byte[] key, Certificate[] chain) throws KeyStoreException { + + } + + @Override + public void engineSetCertificateEntry(String alias, Certificate cert) throws KeyStoreException { + + } + + @Override + public void engineDeleteEntry(String alias) throws KeyStoreException { + + } + + @Override + public Enumeration engineAliases() { + return null; + } + + @Override + public boolean engineContainsAlias(String alias) { + return false; + } + + @Override + public int engineSize() { + return 0; + } + + @Override + public boolean engineIsKeyEntry(String alias) { + return false; + } + + @Override + public boolean engineIsCertificateEntry(String alias) { + return false; + } + + @Override + public String engineGetCertificateAlias(Certificate cert) { + return null; + } + + @Override + public void engineStore(OutputStream stream, char[] password) throws CertificateException, IOException, NoSuchAlgorithmException { + + } + + @Override + public void engineLoad(InputStream stream, char[] password) throws CertificateException, IOException, NoSuchAlgorithmException { + + } +} diff --git a/android/src/test/java/com/oblador/keychain/FakeProvider.java b/android/src/test/java/com/oblador/keychain/FakeProvider.java new file mode 100644 index 00000000..80e83d16 --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/FakeProvider.java @@ -0,0 +1,33 @@ +package com.oblador.keychain; + +import java.security.Provider; +import java.util.HashMap; + +public final class FakeProvider extends Provider { + public static final String NAME = "AndroidKeyStore"; + public final HashMap> mocks = new HashMap<>(); + + public FakeProvider() { + super(NAME, 1.0, "Fake"); + + put("KeyStore.AndroidKeyStore", FakeKeystore.class.getName()); + } + + @Override + public synchronized Service getService(String type, String algorithm) { + MocksForProvider mock; + HashMap inner; + + if (null == (inner = mocks.get(type))) { + mocks.put(type, (inner = new HashMap<>())); + } + + if (null == (mock = inner.get(algorithm))) { + inner.put(algorithm, (mock = new MocksForProvider())); + } + + mock.configure(type, this); + + return mock.service; + } +} diff --git a/android/src/test/java/com/oblador/keychain/FakeSecretKeyFactorySpi.java b/android/src/test/java/com/oblador/keychain/FakeSecretKeyFactorySpi.java new file mode 100644 index 00000000..1fc86b5b --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/FakeSecretKeyFactorySpi.java @@ -0,0 +1,17 @@ +package com.oblador.keychain; + +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; + +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactorySpi; + +public abstract class FakeSecretKeyFactorySpi extends SecretKeyFactorySpi { + + @Override + protected KeySpec engineGetKeySpec(SecretKey key, Class keySpec) throws InvalidKeySpecException { + return doEngineGetKeySpec(key, keySpec); + } + + public abstract KeySpec doEngineGetKeySpec(SecretKey key, Class keySpec); +} diff --git a/android/src/test/java/com/oblador/keychain/KeychainModuleTests.java b/android/src/test/java/com/oblador/keychain/KeychainModuleTests.java new file mode 100644 index 00000000..b07159b5 --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/KeychainModuleTests.java @@ -0,0 +1,301 @@ +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.biometric.BiometricManager; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.oblador.keychain.cipherStorage.CipherStorage; +import com.oblador.keychain.cipherStorage.CipherStorageFacebookConceal; +import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAesCbc; +import com.oblador.keychain.cipherStorage.CipherStorageKeystoreRsaEcb; + +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.Mockito; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.junit.VerificationCollector; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.security.Security; + +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.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +public class KeychainModuleTests { + /** 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 { + Security.insertProviderAt(provider, 0); + } + + @After + public void tearDown() throws Exception { + Security.removeProvider(FakeProvider.NAME); + } + + private ReactApplicationContext getRNContext() { + return new ReactApplicationContext(RuntimeEnvironment.application); + } + + @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")); + } +} diff --git a/android/src/test/java/com/oblador/keychain/MocksForProvider.java b/android/src/test/java/com/oblador/keychain/MocksForProvider.java new file mode 100644 index 00000000..a91f78dd --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/MocksForProvider.java @@ -0,0 +1,83 @@ +package com.oblador.keychain; + +import android.security.keystore.KeyInfo; + +import androidx.annotation.NonNull; + +import org.mockito.MockSettings; +import org.mockito.Mockito; + +import java.security.KeyPair; +import java.security.KeyPairGeneratorSpi; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.spec.InvalidKeySpecException; + +import javax.crypto.SecretKey; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +@SuppressWarnings({"WeakerAccess"}) +public final class MocksForProvider { + public static final String KEY_GENERATOR = "KeyGenerator"; + public static final String KEY_PAIR_GENERATOR = "KeyPairGenerator"; + public static final String KEY_FACTORY = "KeyFactory"; + public static final String KEY_STORE = "KeyStore"; + public static final String SECRET_KEY_FACTORY = "SecretKeyFactory"; + + public final MockSettings settings = withSettings();//.verboseLogging(); + + public final Provider.Service service = Mockito.mock(Provider.Service.class, settings); + public final KeyPairGeneratorSpi kpgSpi = Mockito.mock(KeyPairGeneratorSpi.class, settings); + public final FakeKeyGeneratorSpi kgSpi = Mockito.mock(FakeKeyGeneratorSpi.class, settings); + public final FakeSecretKeyFactorySpi skfSpi = Mockito.mock(FakeSecretKeyFactorySpi.class, settings); + public final FakeKeyFactorySpi kfSpi = Mockito.mock(FakeKeyFactorySpi.class, settings); + public final KeyPair keyPair = Mockito.mock(KeyPair.class, settings); + public final PrivateKey privateKey = Mockito.mock(PrivateKey.class, settings); + public final KeyInfo keyInfo = Mockito.mock(KeyInfo.class, settings); + public final SecretKey secretKey = Mockito.mock(SecretKey.class, settings); + public final KeyStore keyStore = Mockito.mock(KeyStore.class, settings); + + public void configure(@NonNull final String type, @NonNull final Provider provider) { + try { + innerConfiguration(type, provider); + } catch (Throwable fail) { + fail.printStackTrace(System.out); + } + } + + private void innerConfiguration(@NonNull final String type, @NonNull final Provider provider) + throws InvalidKeySpecException, NoSuchAlgorithmException { + when(service.getProvider()).thenReturn(provider); + when(kpgSpi.generateKeyPair()).thenReturn(keyPair); + when(keyPair.getPrivate()).thenReturn(privateKey); + when(keyInfo.isInsideSecureHardware()).thenReturn(true); + + when(kgSpi.engineGenerateKey()).thenReturn(secretKey); + when(skfSpi.engineGetKeySpec(any(), any())).thenReturn(keyInfo); + when(kfSpi.engineGetKeySpec(any(), any())).thenReturn(keyInfo); + + switch (type) { + case KEY_GENERATOR: + when(service.newInstance(any())).thenReturn(kgSpi); + break; + case KEY_PAIR_GENERATOR: + when(service.newInstance(any())).thenReturn(kpgSpi); + break; + case KEY_FACTORY: + when(service.newInstance(isNull())).thenReturn(kfSpi); + break; + case KEY_STORE: + when(service.newInstance(isNull())).thenReturn(keyStore); + break; + case SECRET_KEY_FACTORY: + when(service.newInstance(isNull())).thenReturn(skfSpi); + break; + } + } +} diff --git a/android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbcTests.java b/android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbcTests.java new file mode 100644 index 00000000..f74d10a6 --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbcTests.java @@ -0,0 +1,73 @@ +package com.oblador.keychain.cipherStorage; + +import android.os.Build; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.oblador.keychain.FakeProvider; +import com.oblador.keychain.SecurityLevel; + +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.Mockito; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.junit.VerificationCollector; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.security.Key; +import java.security.Security; + +import javax.crypto.SecretKey; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@RunWith(RobolectricTestRunner.class) +public class CipherStorageKeystoreAesCbcTests { + /** 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(); + + private FakeProvider provider = new FakeProvider(); + + @Before + public void setUp() throws Exception { + Security.insertProviderAt(provider, 0); + } + + @After + public void tearDown() throws Exception { + Security.removeProvider(FakeProvider.NAME); + } + + private ReactApplicationContext getRNContext() { + return new ReactApplicationContext(RuntimeEnvironment.application); + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) + public void testGetSecurityLevel_api23() throws Exception { + final CipherStorageKeystoreRsaEcb instance = new CipherStorageKeystoreRsaEcb(); + final Key mock = Mockito.mock(SecretKey.class); + + final SecurityLevel level = instance.getSecurityLevel(mock); + + assertThat(level, is(SecurityLevel.SECURE_HARDWARE)); + } +} diff --git a/android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcbTests.java b/android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcbTests.java new file mode 100644 index 00000000..53a5691f --- /dev/null +++ b/android/src/test/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcbTests.java @@ -0,0 +1,102 @@ +package com.oblador.keychain.cipherStorage; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.hardware.fingerprint.FingerprintManager; +import android.os.Build; + +import androidx.biometric.BiometricManager; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.oblador.keychain.FakeProvider; +import com.oblador.keychain.SecurityLevel; + +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.Mockito; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.junit.VerificationCollector; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.security.Key; +import java.security.Security; + +import javax.crypto.SecretKey; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +public class CipherStorageKeystoreRsaEcbTests { + /** 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(); + + private FakeProvider provider = new FakeProvider(); + + @Before + public void setUp() throws Exception { + Security.insertProviderAt(provider, 0); + } + + @After + public void tearDown() throws Exception { + Security.removeProvider(FakeProvider.NAME); + } + + private ReactApplicationContext getRNContext() { + return new ReactApplicationContext(RuntimeEnvironment.application); + } + + @Test + @Config(sdk = Build.VERSION_CODES.M) + public void testGetSecurityLevel_api23() throws Exception { + final CipherStorageKeystoreAesCbc instance = new CipherStorageKeystoreAesCbc(); + final Key mock = Mockito.mock(SecretKey.class); + + final SecurityLevel level = instance.getSecurityLevel(mock); + + assertThat(level, is(SecurityLevel.SECURE_HARDWARE)); + } + + @Test + @Config(sdk = Build.VERSION_CODES.P) + public void testVerifySecureHardwareAvailability_api28() throws Exception { + ReactApplicationContext context = getRNContext(); + + // for api24+ system feature should be enabled + 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 + + int result = BiometricManager.from(context).canAuthenticate(); + assertThat(result, is(BiometricManager.BIOMETRIC_SUCCESS)); + + final CipherStorage storage = new CipherStorageKeystoreAesCbc();; + + // expected RsaEcb with fingerprint + assertThat(storage.supportsSecureHardware(), is(true)); + } + +} From 5b4896619bad82fe1c709112fd20591bf974e502 Mon Sep 17 00:00:00 2001 From: Oleksandr Kucherenko Date: Mon, 28 Oct 2019 08:45:21 +0100 Subject: [PATCH 02/27] upgraded gradle --- .gitignore | 50 +++++++++-------- .npmignore | 2 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- build.gradle.kts | 53 +++++++++++++++++++ gradle.properties | 14 +++++ settings.gradle.kts | 4 ++ 6 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore index 535f75be..fb284d25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,32 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + # OSX -# .DS_Store # Xcode -# build/ *.pbxuser !default.pbxuser @@ -22,27 +45,8 @@ DerivedData *.xcuserstate project.xcworkspace -# Android/IntelliJ -# -build/ -.idea +# Android .gradle local.properties +.idea/ *.iml -*.keystore -!debug.keystore -android/gradle/ -android/gradlew -android/gradlew.bat - -# node.js -# -node_modules/ -npm-debug.log -yarn-error.log - -# Bundle artifact -*.jsbundle - -# CocoaPods -/ios/Pods/ diff --git a/.npmignore b/.npmignore index 3d8d0f54..1d5cf10a 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,3 @@ KeychainExample/ +.idea/ +.gradle/ diff --git a/KeychainExample/android/gradle/wrapper/gradle-wrapper.properties b/KeychainExample/android/gradle/wrapper/gradle-wrapper.properties index b6517bb1..ca9d6281 100644 --- a/KeychainExample/android/gradle/wrapper/gradle-wrapper.properties +++ b/KeychainExample/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..62dd959e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,53 @@ +// Copyright (c) Facebook, Inc. and its affiliates. + +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +buildscript { + repositories { + mavenLocal() + google() + jcenter() + maven { url = uri("https://plugins.gradle.org/m2/") } + } + dependencies { + classpath("com.android.tools.build:gradle:3.5.1") + + /* https://github.com/radarsh/gradle-test-logger-plugin */ + classpath("com.adarshr:gradle-test-logger-plugin:2.0.0") + } +} + +//plugins { +// id("com.adarshr.test-logger") version "1.7.0" apply false +//} + +allprojects { + repositories { + mavenLocal() + google() + jcenter() + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + maven { + url = uri("$rootDir/KeychainExample/node_modules/react-native/android") + } + } +} + +allprojects { + configurations.all { + resolutionStrategy.eachDependency { + when (requested.group) { + "com.android.support" -> useVersion("28.0.0") + "android.arch.lifecycle" -> useVersion("1.1.1") + "android.arch.core" -> useVersion("1.1.1") + "com.facebook.fresco" -> useVersion("2.0.+") + } + + when ("${requested.group}:${requested.name}") { + "com.facebook.react:react-native" -> useVersion("0.61.2") + "com.facebook.soloader:soloader" -> useVersion("0.6.+") + } + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..bb003900 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,14 @@ +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx3g -Dkotlin.daemon.jvm.options\="-Xmx3g" -Dfile.encoding=UTF-8 +# +# AndroidX +# +android.useAndroidX=true +android.enableJetifier=true + +## +## Android Support Library +## +#android.useAndroidX=false +#android.enableJetifier=false diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..a88084b9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,4 @@ +include(":android") + +// androidx, ReactNative 0.60+ +includeBuild("KeychainExample/android") From be543847335611a2c8d1e26c87de33ac13fca47e Mon Sep 17 00:00:00 2001 From: Oleksandr Kucherenko Date: Mon, 28 Oct 2019 08:45:41 +0100 Subject: [PATCH 03/27] sample upgraded to RN0.61 --- KeychainExample/App.js | 2 +- KeychainExample/android/app/build.gradle | 138 +- .../android/app/src/main/AndroidManifest.xml | 6 +- .../app/src/main/res/values/strings.xml | 2 +- .../main/res/xml/network_security_config.xml | 8 + KeychainExample/android/build.gradle | 60 +- KeychainExample/android/gradle.properties | 9 +- .../android/gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 55616 bytes KeychainExample/android/gradlew | 22 +- KeychainExample/android/gradlew.bat | 18 +- KeychainExample/android/settings.gradle | 8 +- KeychainExample/package.json | 27 +- KeychainExample/postinstall_cleanup.sh | 15 + KeychainExample/yarn.lock | 3537 +++++++++-------- 14 files changed, 2113 insertions(+), 1739 deletions(-) create mode 100644 KeychainExample/android/app/src/main/res/xml/network_security_config.xml create mode 100755 KeychainExample/postinstall_cleanup.sh diff --git a/KeychainExample/App.js b/KeychainExample/App.js index 148c2007..edfc3f46 100644 --- a/KeychainExample/App.js +++ b/KeychainExample/App.js @@ -76,7 +76,7 @@ export default class KeychainExample extends Component { style={styles.container} > - Keychain Example + Keychain Example RN61 Username - variant.outputs.each { output -> - // For each separate APK per architecture, set a unique version code as described here: - // http://tools.android.com/tech-docs/new-build-system/user-guide/apk-splits - def versionCodes = ["armeabi-v7a":1, "x86":2] - def abi = output.getFilter(OutputFile.ABI) - if (abi != null) { // null for the universal-debug, universal-release variants - output.versionCodeOverride = - versionCodes.get(abi) * 1048576 + defaultConfig.versionCode - } - } + } + + // applicationVariants are e.g. debug, release + applicationVariants.all { variant -> + variant.outputs.each { output -> + // For each separate APK per architecture, set a unique version code as described here: + // http://tools.android.com/tech-docs/new-build-system/user-guide/apk-splits + def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] + def abi = output.getFilter(com.android.build.OutputFile.ABI) + if (abi != null) { // null for the universal-debug, universal-release variants + // abi * 0x100000 + ( 0 < versionCode <= 1048575 ) + // armeabi-v7a => range: [0x100000 : 0x1FFFFF] + // x86 => range: [0x200000 : 0x2FFFFF] + output.versionCodeOverride = (versionCodes.get(abi) << 20) + defaultConfig.versionCode + } } + } + + + packagingOptions { + pickFirst '**/armeabi-v7a/libc++_shared.so' + pickFirst '**/x86/libc++_shared.so' + pickFirst '**/arm64-v8a/libc++_shared.so' + pickFirst '**/x86_64/libc++_shared.so' + pickFirst '**/x86/libjsc.so' + pickFirst '**/armeabi-v7a/libjsc.so' + } } dependencies { - implementation fileTree(dir: "libs", include: ["*.jar"]) - implementation "com.android.support:appcompat-v7:${rootProject.ext.supportLibVersion}" - implementation "com.facebook.react:react-native:+" // From node_modules - implementation project(':react-native-keychain') + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation("com.facebook.react:react-native:+") + + implementation project(':react-native-keychain') + + if (enableHermes) { + def hermesPath = "../../node_modules/hermesvm/android/"; + debugImplementation files(hermesPath + "hermes-debug.aar") + releaseImplementation files(hermesPath + "hermes-release.aar") + } else { + implementation jscFlavor + } + } // Run this once to be able to run the application with BUCK // puts all compile dependencies into folder libs for BUCK to use task copyDownloadableDepsToLibs(type: Copy) { - from configurations.compile - into 'libs' + from configurations.compile + into 'libs' } diff --git a/KeychainExample/android/app/src/main/AndroidManifest.xml b/KeychainExample/android/app/src/main/AndroidManifest.xml index 5f004cee..9719faa9 100644 --- a/KeychainExample/android/app/src/main/AndroidManifest.xml +++ b/KeychainExample/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ @@ -9,7 +10,10 @@ android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="false" - android:theme="@style/AppTheme"> + android:theme="@style/AppTheme" + android:networkSecurityConfig="@xml/network_security_config" + tools:targetApi="28" + tools:ignore="GoogleAppIndexingWarning"> - KeychainExample + KeychainExample RN61 diff --git a/KeychainExample/android/app/src/main/res/xml/network_security_config.xml b/KeychainExample/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..ba4b2307 --- /dev/null +++ b/KeychainExample/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + localhost + 10.0.2.2 + 10.0.3.2 + + diff --git a/KeychainExample/android/build.gradle b/KeychainExample/android/build.gradle index 2cbce0fa..f9f844ac 100644 --- a/KeychainExample/android/build.gradle +++ b/KeychainExample/android/build.gradle @@ -1,39 +1,39 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext { - buildToolsVersion = "28.0.0" - minSdkVersion = 16 - compileSdkVersion = 28 - targetSdkVersion = 26 - supportLibVersion = "27.1.1" - } - repositories { - jcenter() - google() - } - dependencies { - classpath 'com.android.tools.build:gradle:3.1.4' + ext { + buildToolsVersion = "28.0.3" + minSdkVersion = 16 + compileSdkVersion = 28 + targetSdkVersion = 28 + supportLibVersion = "28.0.0" + } + repositories { + jcenter() + google() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.5.1' - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } } allprojects { - repositories { - mavenLocal() - jcenter() - maven { - // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm - url "$rootDir/../node_modules/react-native/android" - } - google() + repositories { + mavenLocal() + jcenter() + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + maven { + name "ReactNativeLocal" + url uri("$rootDir/../node_modules/react-native/android") } -} - - -task wrapper(type: Wrapper) { - gradleVersion = '4.4' - distributionUrl = distributionUrl.replace("bin", "all") + // Android JSC is installed from npm + maven { + name "JscLocal" + url uri("$rootDir/../node_modules/jsc-android/dist") + } + google() + } } diff --git a/KeychainExample/android/gradle.properties b/KeychainExample/android/gradle.properties index 89e0d99e..46f613d3 100644 --- a/KeychainExample/android/gradle.properties +++ b/KeychainExample/android/gradle.properties @@ -10,9 +10,16 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx10248m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx2g -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +# +# AndroidX +# +android.useAndroidX=true +android.enableJetifier=true + diff --git a/KeychainExample/android/gradle/wrapper/gradle-wrapper.jar b/KeychainExample/android/gradle/wrapper/gradle-wrapper.jar index 01b8bf6b1f99cad9213fc495b33ad5bbab8efd20..5c2d1cf016b3885f6930543d57b744ea8c220a1a 100644 GIT binary patch literal 55616 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

<5G2M1WZP5 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