Skip to content
Merged
2 changes: 1 addition & 1 deletion app/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@
<string name="recovery_network_unavailable">No network connection. Please check your internet and try again.</string>
<string name="recovery_network_outdated">The app is outdated and can no longer communicate with the server. Please update the app on the Google Play Store.</string>
<string name="recovery_network_token_unavailable">There was a network issue, please try again.</string>
<string name="recovery_network_token_request_rejected">Lost PersonalID configuration with server, please recover account.</string>
<string name="recovery_network_token_request_rejected">Lost PersonalID configuration with server, please recover your Personal ID account and retry</string>
<string name="recovery_app_manager">Go to App Manager</string>
<string name="recovery_retry">Retry Recovery</string>
<string name="notification_channel_errors_title">Errors</string>
Expand Down
43 changes: 36 additions & 7 deletions app/src/org/commcare/android/security/AesKeyStoreHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package org.commcare.android.security

import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import androidx.annotation.RequiresApi
import org.commcare.utils.EncryptionKeyAndTransform
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey

Expand All @@ -18,20 +20,47 @@ class AesKeyStoreHandler(
private val needsUserAuth: Boolean
) : KeyStoreHandler {

companion object {
private const val TRANSFORM = "AES/CBC/PKCS7Padding";
}

override fun getKeyOrGenerate(): EncryptionKeyAndTransform {
val keystore = AndroidKeyStore.instance
val entry = keystore.getEntry(alias, null)
val key = if (entry is KeyStore.SecretKeyEntry) {
entry.secretKey
} else {
generateAesKey(alias, needsUserAuth)
var key = getKeyIfExists()
if (key == null) {
key = generateAesKey(alias, needsUserAuth);
}
return EncryptionKeyAndTransform(
key,
"AES/CBC/PKCS7Padding"
TRANSFORM
)
}

override fun isKeyValid(): Boolean {
val key = getKeyIfExists() ?: return false
try {
val cipher = Cipher.getInstance(TRANSFORM);
cipher.init(Cipher.ENCRYPT_MODE, key);
return true
} catch (_: KeyPermanentlyInvalidatedException) {
return false
}
}

override fun deleteKey() {
AndroidKeyStore.instance.deleteEntry(alias)
}

fun getKeyIfExists(): SecretKey? {
val keystore = AndroidKeyStore.instance
if (keystore.containsAlias(alias) && keystore.getEntry(alias, null) is KeyStore.SecretKeyEntry) {
val entry = keystore.getEntry(alias, null)
if (entry is KeyStore.SecretKeyEntry) {
return entry.secretKey
}
}
return null
}

private fun generateAesKey(alias: String, needsUserAuth: Boolean): SecretKey {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
Expand Down
6 changes: 6 additions & 0 deletions app/src/org/commcare/android/security/AndroidKeyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ object AndroidKeyStore {
load(null)
}
}

fun deleteKey(alias: String) {
if (instance.containsAlias(alias)) {
instance.deleteEntry(alias)
}
}
}
2 changes: 2 additions & 0 deletions app/src/org/commcare/android/security/KeyStoreHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ import org.commcare.utils.EncryptionKeyAndTransform

interface KeyStoreHandler {
fun getKeyOrGenerate(): EncryptionKeyAndTransform
fun isKeyValid(): Boolean
fun deleteKey()
}
8 changes: 8 additions & 0 deletions app/src/org/commcare/android/security/RsaKeyStoreHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ class RsaKeyStoreHandler(
}
}

override fun isKeyValid(): Boolean {
return doesKeyExist()
}

override fun deleteKey() {
AndroidKeyStore.instance.deleteEntry(alias)
}

fun doesKeyExist(): Boolean {
val keystore = AndroidKeyStore.instance
if (keystore.containsAlias(alias)) {
Expand Down
25 changes: 23 additions & 2 deletions app/src/org/commcare/connect/PersonalIdManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.commcare.android.database.connect.models.ConnectLinkedAppRecord;
import org.commcare.android.database.connect.models.ConnectUserRecord;
import org.commcare.android.database.connect.models.PersonalIdSessionData;
import org.commcare.android.database.global.models.GlobalErrorRecord;
import org.commcare.connect.database.ConnectAppDatabaseUtil;
import org.commcare.connect.database.ConnectDatabaseHelper;
import org.commcare.connect.database.ConnectDatabaseUtils;
Expand All @@ -39,10 +40,14 @@
import org.commcare.connect.workers.ConnectHeartbeatWorker;
import org.commcare.core.network.AuthInfo;
import org.commcare.dalvik.R;
import org.commcare.google.services.analytics.CCAnalyticsEvent;
import org.commcare.google.services.analytics.FirebaseAnalyticsUtil;
import org.commcare.util.LogTypes;
import org.commcare.utils.BiometricsHelper;
import org.commcare.utils.CrashUtil;
import org.commcare.utils.EncryptionKeyProvider;
import org.commcare.utils.GlobalErrorUtil;
import org.commcare.utils.GlobalErrors;
import org.commcare.views.dialogs.StandardAlertDialog;
import org.javarosa.core.io.StreamsUtil;
import org.javarosa.core.services.Logger;
Expand All @@ -62,6 +67,7 @@
* @author dviggiano
*/
public class PersonalIdManager {
public static final String BIOMETRIC_INVALIDATION_KEY = "biometric-invalidation-key";
private static final long DAYS_TO_SECOND_OFFER = 30;

/**
Expand Down Expand Up @@ -177,6 +183,8 @@ public boolean isloggedIn() {
}

public void unlockConnect(CommCareActivity<?> activity, ConnectActivityCompleteListener callback) {
logBiometricInvalidations();

BiometricPrompt.AuthenticationCallback callbacks = new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
Expand Down Expand Up @@ -209,6 +217,19 @@ public void onAuthenticationFailed() {
}
}

private void logBiometricInvalidations() {
EncryptionKeyProvider encryptionKeyProvider = new EncryptionKeyProvider(parentActivity, true,
BIOMETRIC_INVALIDATION_KEY);
if (!encryptionKeyProvider.isKeyValid()) {
FirebaseAnalyticsUtil.reportBiometricInvalidated();

// reset key
encryptionKeyProvider.deleteKey();
encryptionKeyProvider.getKeyForEncryption();
}
}


public void completeSignin() {
personalIdSatus = PersonalIdStatus.LoggedIn;
scheduleHearbeat();
Expand All @@ -224,10 +245,10 @@ public void handleFinishedActivity(CommCareActivity<?> activity, int resultCode)


public void forgetUser(String reason) {
if (ConnectDatabaseHelper.dbExists(parentActivity)) {
if (ConnectDatabaseHelper.dbExists()) {
FirebaseAnalyticsUtil.reportCccDeconfigure(reason);
}
ConnectUserDatabaseUtil.forgetUser(parentActivity);
ConnectUserDatabaseUtil.forgetUser();
personalIdSatus = PersonalIdStatus.NotIntroduced;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ public static void handleReceivedDbPassphrase(Context context, String remotePass
}
}

public static boolean dbExists(Context context) {
return DatabaseConnectOpenHelper.dbExists(context);
public static boolean dbExists() {
return DatabaseConnectOpenHelper.dbExists();
}

public static boolean isDbBroken() {
Expand Down Expand Up @@ -105,7 +105,6 @@ public static void crashDb() {

public static void crashDb(GlobalErrors error) {
GlobalErrorUtil.addError(new GlobalErrorRecord(new Date(), error.ordinal()));

throw new RuntimeException("Connect database crash");
}

Expand Down
49 changes: 23 additions & 26 deletions app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static ConnectUserRecord getUser(Context context) {
if (context == null) {
throw new IllegalArgumentException("Context must not be null");
}
if (!ConnectDatabaseHelper.dbExists(context)) {
if (!ConnectDatabaseHelper.dbExists()) {
return null;
}
try {
Expand All @@ -38,34 +38,31 @@ public static void storeUser(Context context, ConnectUserRecord user) {
if (user == null) {
throw new IllegalArgumentException("User must not be null");
}
try {
ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class).write(user);
} catch (Exception e) {
Logger.exception("Failed to store user", e);
throw new RuntimeException("Failed to store user in Connect database", e);
}
try {
ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class).write(user);
} catch (Exception e) {
Logger.exception("Failed to store user", e);
throw new RuntimeException("Failed to store user in Connect database", e);
}
}


public static void forgetUser(Context context) {
if (context == null) {
throw new IllegalArgumentException("Context must not be null");
}
try {
DatabaseConnectOpenHelper.deleteDb(context);
CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll();
ConnectDatabaseHelper.dbBroken = false;
ConnectDatabaseHelper.teardown();
} catch (IllegalStateException e) {
Logger.exception("Database access error while forgetting user", e);
throw new RuntimeException("Failed to access database while cleaning up", e);
} catch (SecurityException e) {
Logger.exception("Permission denied while deleting database", e);
throw new RuntimeException("Failed to delete database due to permissions", e);
} catch (Exception e) {
Logger.exception("Failed to forget user", e);
throw new RuntimeException("Failed to clean up Connect database", e);
}
public static void forgetUser() {
try {
DatabaseConnectOpenHelper.deleteDb();
CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll();
ConnectDatabaseHelper.dbBroken = false;
ConnectDatabaseHelper.teardown();
} catch (IllegalStateException e) {
Logger.exception("Database access error while forgetting user", e);
throw new RuntimeException("Failed to access database while cleaning up", e);
} catch (SecurityException e) {
Logger.exception("Permission denied while deleting database", e);
throw new RuntimeException("Failed to delete database due to permissions", e);
} catch (Exception e) {
Logger.exception("Failed to forget user", e);
throw new RuntimeException("Failed to clean up Connect database", e);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
Expand All @@ -15,7 +16,6 @@
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavDirections;
import androidx.navigation.Navigation;

import org.commcare.activities.connect.viewmodel.PersonalIdSessionDataViewModel;
import org.commcare.connect.ConnectConstants;
import org.commcare.connect.PersonalIdManager;
Expand All @@ -25,6 +25,7 @@
import org.commcare.google.services.analytics.AnalyticsParamValue;
import org.commcare.google.services.analytics.FirebaseAnalyticsUtil;
import org.commcare.utils.BiometricsHelper;
import org.commcare.utils.EncryptionKeyProvider;
import org.javarosa.core.services.Logger;

import java.util.Locale;
Expand All @@ -36,13 +37,13 @@
import static androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED;
import static org.commcare.android.database.connect.models.PersonalIdSessionData.BIOMETRIC_TYPE;
import static org.commcare.android.database.connect.models.PersonalIdSessionData.PIN;
import static org.commcare.connect.PersonalIdManager.BIOMETRIC_INVALIDATION_KEY;
import static org.commcare.utils.ViewUtils.showSnackBarWithOk;

/**
* Fragment that handles biometric or PIN verification for Connect ID authentication.
*/
public class PersonalIdBiometricConfigFragment extends Fragment {

private BiometricManager biometricManager;
private BiometricPrompt.AuthenticationCallback biometricCallback;
private static final String KEY_ATTEMPTING_FINGERPRINT = "attempting_fingerprint";
Expand Down Expand Up @@ -242,6 +243,7 @@ private void navigateForward(boolean enrollmentFailed) {
boolean isConfigured = fingerprint == BiometricsHelper.ConfigurationStatus.Configured ||
pin == BiometricsHelper.ConfigurationStatus.Configured;
if (isConfigured) {
storeBiometricInvalidationKey();
if (Boolean.FALSE.equals(personalIdSessionDataViewModel.getPersonalIdSessionData().getDemoUser())) {
NavHostFragment.findNavController(this).navigate(navigateToOtpScreen());
} else {
Expand All @@ -255,6 +257,13 @@ private void navigateForward(boolean enrollmentFailed) {
}
}

/**
* Generates a biometric linked key in Android Key Store if not already there
*/
private void storeBiometricInvalidationKey() {
new EncryptionKeyProvider(requireContext(), true, BIOMETRIC_INVALIDATION_KEY).getKeyForEncryption();
}

private NavDirections navigateToBiometricEnrollmentFailed() {
return PersonalIdBiometricConfigFragmentDirections.actionPersonalidBiometricConfigToPersonalidMessage(
getString(R.string.connect_biometric_enroll_fail_title),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@ public class CCAnalyticsEvent {
static final String CCC_PAYMENT_CONFIRMATION_INTERACT = "ccc_payment_confirmation_interact";
static final String CCC_NOTIFICATION_TYPE = "ccc_notification_type";
static final String CCC_REKEYED_DB = "ccc_rekeyed_db";
static final String CCC_BIOMETRIC_INVALIDATED = "ccc_biometric_invalidated";

}
Original file line number Diff line number Diff line change
Expand Up @@ -526,4 +526,8 @@ public static void reportNotificationType(String notificationType) {
public static void reportRekeyedDatabase() {
reportEvent(CCAnalyticsEvent.CCC_REKEYED_DB);
}

public static void reportBiometricInvalidated() {
reportEvent(CCAnalyticsEvent.CCC_BIOMETRIC_INVALIDATED);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import net.sqlcipher.database.SQLiteException;
import net.sqlcipher.database.SQLiteOpenHelper;

import org.commcare.CommCareApplication;
import org.commcare.android.database.connect.models.ConnectAppRecord;
import org.commcare.android.database.connect.models.ConnectJobAssessmentRecord;
import org.commcare.android.database.connect.models.ConnectJobDeliveryFlagRecord;
Expand Down Expand Up @@ -64,16 +65,16 @@ public DatabaseConnectOpenHelper(Context context) {
this.mContext = context;
}

private static File getDbFile(Context context) {
return context.getDatabasePath(CONNECT_DB_LOCATOR);
private static File getDbFile() {
return CommCareApplication.instance().getDatabasePath(CONNECT_DB_LOCATOR);
}

public static boolean dbExists(Context context) {
return getDbFile(context).exists();
public static boolean dbExists() {
return getDbFile().exists();
}

public static void deleteDb(Context context) {
getDbFile(context).delete();
public static void deleteDb() {
getDbFile().delete();
}

public static void rekeyDB(SQLiteDatabase db, String newPassphrase) throws Base64DecoderException {
Expand Down
8 changes: 8 additions & 0 deletions app/src/org/commcare/utils/EncryptionKeyProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,12 @@ private KeyStoreHandler getHandler(boolean isEncryptMode) {
return rsaKeystoreHandler;
}
}

public boolean isKeyValid() {
return getHandler(false).isKeyValid();
}

public void deleteKey() {
getHandler(false).deleteKey();
}
}