Skip to content

Commit

Permalink
keychain/android: let client app access keychain from native code
Browse files Browse the repository at this point in the history
This change extracts the Keychain related code into separate class which
to be used by the client apps which would like to access it from the
native side.
  • Loading branch information
mgenov committed Dec 25, 2019
1 parent bfeb5af commit 0ba7584
Show file tree
Hide file tree
Showing 8 changed files with 321 additions and 166 deletions.
64 changes: 64 additions & 0 deletions android/src/main/java/com/oblador/keychain/Keychain.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.oblador.keychain;

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

import com.oblador.keychain.exceptions.CryptoFailedException;
import com.oblador.keychain.exceptions.EmptyParameterException;
import com.oblador.keychain.exceptions.KeyStoreAccessException;

/**
* Keychain is an Keychain module which abstracts an encrypted the keychain related lookups and updates.
* <p/>
* <p>
* The flow is the following:
* <pre>
* setGenericPassword(service1, "user1", "pass1") -> get service1 keys -> encrypt user/pass -> store user/pass.
* </pre>
*
* @author Miroslav Genov <miroslav.genov@clouway.com>
*/
public interface Keychain {
/**
* Gets the SecurityLevel of the Keychain, e.g software, hardware and etc.
* @return the security level
*/
SecurityLevel getSecurityLevel();

/**
* Gets the {@link ServiceCredentials} associated with the provided service
*
* @param service the service to which credentials are associated
* @return the service credentials associated with the provided service
* @throws CryptoFailedException in case of crypto failure
* @throws KeyStoreAccessException in case of error during accessing the keystore
*/
@Nullable
ServiceCredentials getGenericPasswordForOptions(String service) throws CryptoFailedException, KeyStoreAccessException;

/**
* Checks whether the provided credentials exists.
* @param server the service id
* @return true if credentials exists and false in other case
*/
boolean hasInternetCredentialsForServer(@NonNull String server);

/**
* Sets new credentials of the provided service
* @param service the name of the service
* @param username the username value
* @param password the password value
* @param minimumSecurityLevel the minimum security level
* @throws EmptyParameterException is password is empty
* @throws CryptoFailedException in case of crypto failure
*/
void setGenericPasswordForOptions(String service, String username, String password, String minimumSecurityLevel) throws EmptyParameterException, CryptoFailedException;

/**
* Resets credentials of a given service.
*
* @param service the service of which credentials to be reset
* @throws KeyStoreAccessException in case of keystore access error
*/
void resetGenericPasswordForOptions(String service) throws KeyStoreAccessException;
}
169 changes: 13 additions & 156 deletions android/src/main/java/com/oblador/keychain/KeychainModule.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.oblador.keychain;

import android.os.Build;
import androidx.annotation.NonNull;
import android.support.annotation.NonNull;
import android.util.Log;

import com.facebook.react.bridge.Arguments;
Expand All @@ -11,12 +10,6 @@
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.oblador.keychain.PrefsStorage.ResultSet;
import com.oblador.keychain.cipherStorage.CipherStorage;
import com.oblador.keychain.cipherStorage.CipherStorage.DecryptionResult;
import com.oblador.keychain.cipherStorage.CipherStorage.EncryptionResult;
import com.oblador.keychain.cipherStorage.CipherStorageFacebookConceal;
import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAESCBC;
import com.oblador.keychain.exceptions.CryptoFailedException;
import com.oblador.keychain.exceptions.EmptyParameterException;
import com.oblador.keychain.exceptions.KeyStoreAccessException;
Expand All @@ -34,10 +27,8 @@ public class KeychainModule extends ReactContextBaseJavaModule {
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<String, CipherStorage> cipherStorageMap = new HashMap<>();
private final PrefsStorage prefsStorage;
private final Keychain keyChain;

@Override
public String getName() {
Expand All @@ -46,15 +37,9 @@ public String getName() {

public KeychainModule(ReactApplicationContext reactContext) {
super(reactContext);
prefsStorage = new PrefsStorage(reactContext);

addCipherStorageToMap(new CipherStorageFacebookConceal(reactContext));
addCipherStorageToMap(new CipherStorageKeystoreAESCBC());
keyChain = Keychains.create(reactContext);
}

private void addCipherStorageToMap(CipherStorage cipherStorage) {
cipherStorageMap.put(cipherStorage.getCipherStorageName(), cipherStorage);
}

@Nullable
@Override
Expand All @@ -68,24 +53,13 @@ public Map<String, Object> getConstants() {

@ReactMethod
public void getSecurityLevel(Promise promise) {
promise.resolve(getSecurityLevel().name());
promise.resolve(keyChain.getSecurityLevel().name());
}

@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);

keyChain.setGenericPasswordForOptions(service, username, password, minimumSecurityLevel);
promise.resolve(true);
} catch (EmptyParameterException e) {
Log.e(KEYCHAIN_MODULE, e.getMessage());
Expand All @@ -99,24 +73,19 @@ public void setGenericPasswordForOptions(String service, String username, String
@ReactMethod
public void getGenericPasswordForOptions(String service, Promise promise) {
try {
service = getDefaultServiceIfNull(service);

CipherStorage currentCipherStorage = getCipherStorageForCurrentAPILevel();
ServiceCredentials savedCredentials = keyChain.getGenericPasswordForOptions(service);

ResultSet resultSet = prefsStorage.getEncryptedEntry(service);
if (resultSet == null) {
if (savedCredentials == null) {
Log.e(KEYCHAIN_MODULE, "No entry found for service: " + service);
promise.resolve(false);
return;
}

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);
credentials.putString("username", savedCredentials.username);
credentials.putString("password", savedCredentials.password);

promise.resolve(credentials);
} catch (KeyStoreAccessException e) {
Expand All @@ -128,53 +97,10 @@ public void getGenericPasswordForOptions(String service, Promise promise) {
}
}

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);
}

// 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

DecryptionResult decryptionResult = oldCipherStorage.decrypt(service, resultSet.usernameBytes, resultSet.passwordBytes);
// encrypt using the current cipher storage

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 decryptionResult;
}

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);
}

@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);

keyChain.resetGenericPasswordForOptions(service);
promise.resolve(true);
} catch (KeyStoreAccessException e) {
Log.e(KEYCHAIN_MODULE, e.getMessage());
Expand All @@ -184,11 +110,10 @@ public void resetGenericPasswordForOptions(String service, Promise promise) {

@ReactMethod
public void hasInternetCredentialsForServer(@NonNull String server, Promise promise) {
final String defaultService = getDefaultServiceIfNull(server);
boolean hasInternetCredentials = keyChain.hasInternetCredentialsForServer(server);

ResultSet resultSet = prefsStorage.getEncryptedEntry(defaultService);
if (resultSet == null) {
Log.e(KEYCHAIN_MODULE, "No entry found for service: " + defaultService);
if (!hasInternetCredentials) {
Log.e(KEYCHAIN_MODULE, "No entry found for service: " + server);
promise.resolve(false);
return;
}
Expand Down Expand Up @@ -226,78 +151,10 @@ public void getSupportedBiometryType(Promise promise) {
}
}

// 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;
}

private void validateCipherStorageSecurityLevel(CipherStorage cipherStorage, SecurityLevel requiredLevel) throws CryptoFailedException {
if (cipherStorage.securityLevel().satisfiesSafetyThreshold(requiredLevel)) {
return;
}

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()));
}


private CipherStorage getCipherStorageByName(String cipherStorageName) {
return cipherStorageMap.get(cipherStorageName);
}

private boolean isFingerprintAuthAvailable() {
return DeviceAvailability.isFingerprintAuthAvailable(getReactApplicationContext());
}

private boolean isSecureHardwareAvailable() {
try {
return getCipherStorageForCurrentAPILevel().supportsSecureHardware();
} catch (CryptoFailedException e) {
return false;
}
}

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;
}
}



@NonNull
private String getDefaultServiceIfNull(String service) {
return service == null ? EMPTY_STRING : service;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@

public class KeychainPackage implements ReactPackage {

public KeychainPackage() {

}

@Override
public List<NativeModule> createNativeModules(
ReactApplicationContext reactContext) {
Expand Down
34 changes: 34 additions & 0 deletions android/src/main/java/com/oblador/keychain/Keychains.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.oblador.keychain;

import android.content.Context;

import com.oblador.keychain.cipherStorage.CipherStorage;
import com.oblador.keychain.cipherStorage.CipherStorageFacebookConceal;
import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAESCBC;

import java.util.LinkedHashMap;

/**
* Keychains is a factory class used to create Keychain objects used for storing of secrets.
*
* @author Miroslav Genov <miroslav.genov@clouway.com>
*/
public final class Keychains {

/**
* Creates a new {@link Keychain} instance that is using shared preferences as storage.
*
* @param context the android context
* @return the newly created Keychain instance
*/
public static Keychain create(Context context) {
final CipherStorage facebookConcealCipherStorage = new CipherStorageFacebookConceal(context);
final CipherStorage keystoreCipherStorage = new CipherStorageKeystoreAESCBC();

return new SharedRefKeychain(context, new LinkedHashMap<String, CipherStorage>() {{
put(facebookConcealCipherStorage.getCipherStorageName(), facebookConcealCipherStorage);
put(keystoreCipherStorage.getCipherStorageName(), keystoreCipherStorage);
}});
}

}
5 changes: 2 additions & 3 deletions android/src/main/java/com/oblador/keychain/PrefsStorage.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import androidx.annotation.NonNull;
import android.util.Base64;

import com.facebook.react.bridge.ReactApplicationContext;
import com.oblador.keychain.cipherStorage.CipherStorage.EncryptionResult;
import com.oblador.keychain.cipherStorage.CipherStorageFacebookConceal;

Expand All @@ -26,8 +25,8 @@ public ResultSet(String cipherStorageName, byte[] usernameBytes, byte[] password

private final SharedPreferences prefs;

public PrefsStorage(ReactApplicationContext reactContext) {
this.prefs = reactContext.getSharedPreferences(KEYCHAIN_DATA, Context.MODE_PRIVATE);
public PrefsStorage(Context context) {
this.prefs = context.getSharedPreferences(KEYCHAIN_DATA, Context.MODE_PRIVATE);
}

public ResultSet getEncryptedEntry(@NonNull String service) {
Expand Down

0 comments on commit 0ba7584

Please sign in to comment.