diff --git a/app/assets/locales/android_translatable_strings.txt b/app/assets/locales/android_translatable_strings.txt index 3873028d66..b5af54a02c 100644 --- a/app/assets/locales/android_translatable_strings.txt +++ b/app/assets/locales/android_translatable_strings.txt @@ -196,11 +196,12 @@ post.generic.error=An error occurred while preparing the HTTP request post.dialog.title=Claiming... post.dialog.body=Claiming chosen data from server post.io.error=Error reading server response: ${0} -post.unknown.response=Received unknown resonse code (${0}) from server +post.unknown.response=Received unknown response code (${0}) from server post.client.error=Client-side error (code ${0}) received from network request. post.server.error=Server-side error (code ${0}) received from network request. post.gone.error=Case is not present on server. post.conflict.error=You have already claimed this case. +post.cache.encryption.key.error=Encryption error while processing server response: ${0} version.id.long=CommCare Android, version "${0}"(${1}). App v${5}. CommCare Version ${2}. Build ${3}, built on: ${4} version.id.short=CCDroid:"${0}"(${1}). v${5} CC${2}b[${3}] on ${4} @@ -263,6 +264,7 @@ app.handled.error.explanation=CommCare will now restart. You may need to correct app.key.request.message=Another android application has requested the ability to communicate securely with CommCare. This application will be able to pass information to CommCare and trigger actions (submission, login, etc). Do you want to grant access? app.key.request.grant=Grant Access app.key.request.deny=Deny Access +app.key.request.encryption.key.error=Error during encryption Key generation key.manage.title=Logging in key.manage.start=Logging in @@ -924,6 +926,7 @@ nfc.write.type.not.supported=The well-known type you tried to write is not suppo nfc.write.io.error=An IO error occurred while attempting to write the Ndef message to your NFC tag nfc.write.msg.malformed=The Ndef message that you attempted to write was malformed nfc.write.success=NFC write was successful! +nfc.write.encryption.key.error=An error occurred while handling the encryption key nfc.missing.domain=A domain must be provided when specifying a custom type for your NFC action nfc.read.success=NFC read was successful! nfc.read.io.error=An IO error occurred while attempting to read the Ndef message @@ -934,6 +937,7 @@ nfc.read.error.no.ndef=The message on this NFC tag is not of a format that Andro nfc.read.no.data=The provided NFC tag had no data on it to read nfc.read.error.unsupported=The message read from the NFC tag is of a type not supported by CommCare nfc.read.error.mismatch=The message read from the NFC tag does not match the type specified by this app's configuration +nfc.read.msg.decryption.key.error=An error occurred while handling the encryption key reason.for.quarantine.title=Reason for Quarantine reason.for.quarantine.prefix=Quarantine Reason: diff --git a/app/build.gradle b/app/build.gradle index 5e119e9b23..7242d25882 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -136,31 +136,30 @@ dependencies { } ext { - // Obtained from ~/.gradle/gradle.properties on build server (mobile agent), or your local // Obtained from ~/.gradle/gradle.properties on build server (mobile agent), or your local // ~/.gradle/gradle.properties file, or loads default empty strings if neither is present - MAPBOX_SDK_API_KEY = project.properties['MAPBOX_SDK_API_KEY'] ?: "" - ANALYTICS_TRACKING_ID_DEV = project.properties['ANALYTICS_TRACKING_ID_DEV'] ?: "" - ANALYTICS_TRACKING_ID_LIVE = project.properties['ANALYTICS_TRACKING_ID_LIVE'] ?: "" - GOOGLE_PLAY_MAPS_API_KEY = project.properties['GOOGLE_PLAY_MAPS_API_KEY'] ?: "" - RELEASE_STORE_FILE = project.properties['RELEASE_STORE_FILE'] ?: "." - RELEASE_STORE_PASSWORD = project.properties['RELEASE_STORE_PASSWORD'] ?: "" - RELEASE_KEY_ALIAS = project.properties['RELEASE_KEY_ALIAS'] ?: "" - RELEASE_KEY_PASSWORD = project.properties['RELEASE_KEY_PASSWORD'] ?: "" + MAPBOX_SDK_API_KEY = project.properties['MAPBOX_SDK_API_KEY'] ?: '' + ANALYTICS_TRACKING_ID_DEV = project.properties['ANALYTICS_TRACKING_ID_DEV'] ?: '' + ANALYTICS_TRACKING_ID_LIVE = project.properties['ANALYTICS_TRACKING_ID_LIVE'] ?: '' + GOOGLE_PLAY_MAPS_API_KEY = project.properties['GOOGLE_PLAY_MAPS_API_KEY'] ?: '' + RELEASE_STORE_FILE = project.properties['RELEASE_STORE_FILE'] ?: '.' + RELEASE_STORE_PASSWORD = project.properties['RELEASE_STORE_PASSWORD'] ?: '' + RELEASE_KEY_ALIAS = project.properties['RELEASE_KEY_ALIAS'] ?: '' + RELEASE_KEY_PASSWORD = project.properties['RELEASE_KEY_PASSWORD'] ?: '' TRUSTED_SOURCE_PUBLIC_KEY = project.properties['TRUSTED_SOURCE_PUBLIC_KEY'] ?: "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHiuy2ULV4pobkuQN2TEjmR1tn" + "HJ+F335hm/lVdaFQzvBmeq64MUMbumheVLDJaSUiAVzqSHDKJWH01ZQRowqBYjwo" + "ycVSQSeO2glc6XZZ+CJudAPXe8iFWLQp3kBBnBmVcBXCOQFO7aLgQMv4nqKZsLW0" + "HaAJkjpnc165Os+aYwIDAQAB" - GOOGLE_SERVICES_API_KEY = project.properties['GOOGLE_SERVICES_API_KEY'] ?: "" - QA_BETA_APP_ID = "" - STANDALONE_APP_ID = "" - LTS_APP_ID = "" - COMMCARE_APP_ID = "" - HQ_API_USERNAME = project.properties['HQ_API_USERNAME'] ?: "" - HQ_API_PASSWORD = project.properties['HQ_API_PASSWORD'] ?: "" - TEST_BUILD_TYPE = project.properties['TEST_BUILD_TYPE'] ?: "debug" - FIREBASE_DATABASE_URL = project.properties['FIREBASE_DATABASE_URL'] ?: "" + GOOGLE_SERVICES_API_KEY = project.properties['GOOGLE_SERVICES_API_KEY'] ?: '' + QA_BETA_APP_ID = '' + STANDALONE_APP_ID = '' + LTS_APP_ID = '' + COMMCARE_APP_ID = '' + HQ_API_USERNAME = project.properties['HQ_API_USERNAME'] ?: '' + HQ_API_PASSWORD = project.properties['HQ_API_PASSWORD'] ?: '' + TEST_BUILD_TYPE = project.properties['TEST_BUILD_TYPE'] ?: 'debug' + FIREBASE_DATABASE_URL = project.properties['FIREBASE_DATABASE_URL'] ?: '' } afterEvaluate { diff --git a/app/src/META-INF/services/org.commcare.util.IKeyStoreEncryptionKeyProvider b/app/src/META-INF/services/org.commcare.util.IKeyStoreEncryptionKeyProvider new file mode 100644 index 0000000000..23a1b0ed00 --- /dev/null +++ b/app/src/META-INF/services/org.commcare.util.IKeyStoreEncryptionKeyProvider @@ -0,0 +1 @@ +org.commcare.utils.KeyStoreEncryptionKeyProvider \ No newline at end of file diff --git a/app/src/org/commcare/CommCareApplication.java b/app/src/org/commcare/CommCareApplication.java index 3a867aad12..560edce482 100644 --- a/app/src/org/commcare/CommCareApplication.java +++ b/app/src/org/commcare/CommCareApplication.java @@ -140,6 +140,8 @@ import okhttp3.MultipartBody; import okhttp3.RequestBody; +import static org.commcare.util.EncryptionKeyHelper.CC_IN_MEMORY_ENCRYPTION_KEY_ALIAS; + public class CommCareApplication extends MultiDexApplication { private static final String TAG = CommCareApplication.class.getSimpleName(); diff --git a/app/src/org/commcare/activities/InstallFromListActivity.java b/app/src/org/commcare/activities/InstallFromListActivity.java index f776b5259f..91de17ae95 100644 --- a/app/src/org/commcare/activities/InstallFromListActivity.java +++ b/app/src/org/commcare/activities/InstallFromListActivity.java @@ -32,6 +32,7 @@ import org.commcare.preferences.GlobalPrivilegesManager; import org.commcare.tasks.ModernHttpTask; import org.commcare.tasks.templates.CommCareTaskConnector; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.util.LogTypes; import org.commcare.utils.ConnectivityStatus; import org.commcare.views.UserfacingErrorHandling; @@ -355,6 +356,13 @@ public void handleIOException(IOException exception) { repeatRequestOrShowResults(true, false); } + @Override + public void handleEncryptionKeyException(EncryptionKeyHelper.EncryptionKeyException exception) { + Logger.log(LogTypes.TYPE_ERROR_ENCRYPTION_KEY, + "An ENcryptionKeyException was encountered when pocessing available apps request: " + exception.getMessage()); + repeatRequestOrShowResults(true, false); + } + private void handleRequestError(int responseCode, boolean couldBeUserError) { Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Request to " + urlCurrentlyRequestingFrom + " in get available apps request " + diff --git a/app/src/org/commcare/activities/KeyAccessRequestActivity.java b/app/src/org/commcare/activities/KeyAccessRequestActivity.java index 4a993dee1e..05dcb15288 100644 --- a/app/src/org/commcare/activities/KeyAccessRequestActivity.java +++ b/app/src/org/commcare/activities/KeyAccessRequestActivity.java @@ -4,12 +4,16 @@ import android.os.Bundle; import android.widget.Button; import android.widget.TextView; +import android.widget.Toast; import org.commcare.CommCareApplication; import org.commcare.android.database.global.models.AndroidSharedKeyRecord; import org.commcare.dalvik.R; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.views.ManagedUi; import org.commcare.views.UiElement; +import org.javarosa.core.services.Logger; +import org.javarosa.core.services.locale.Localization; import androidx.appcompat.app.AppCompatActivity; @@ -34,7 +38,14 @@ protected void onCreate(Bundle savedInstanceState) { grantButton.setOnClickListener(v -> { Intent response = new Intent(getIntent()); - AndroidSharedKeyRecord record = AndroidSharedKeyRecord.generateNewSharingKey(); + AndroidSharedKeyRecord record = null; + try { + record = AndroidSharedKeyRecord.generateNewSharingKey(); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { + Toast.makeText(this, Localization.get("app.key.request.encryption.key.error"), Toast.LENGTH_LONG).show(); + Logger.exception("Exception while generating encryption key ", e); + return; + } CommCareApplication.instance().getGlobalStorage(AndroidSharedKeyRecord.class).write(record); record.writeResponseToIntent(response); setResult(AppCompatActivity.RESULT_OK, response); diff --git a/app/src/org/commcare/activities/PostRequestActivity.java b/app/src/org/commcare/activities/PostRequestActivity.java index 82d32563a3..a001f22965 100644 --- a/app/src/org/commcare/activities/PostRequestActivity.java +++ b/app/src/org/commcare/activities/PostRequestActivity.java @@ -19,6 +19,7 @@ import org.commcare.tasks.DataPullTask; import org.commcare.tasks.ModernHttpTask; import org.commcare.tasks.templates.CommCareTaskConnector; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.views.ManagedUi; import org.commcare.views.UiElement; import org.commcare.views.dialogs.CustomProgressDialog; @@ -214,6 +215,11 @@ public void handleIOException(IOException exception) { } } + @Override + public void handleEncryptionKeyException(EncryptionKeyHelper.EncryptionKeyException exception) { + enterErrorState(Localization.get("post.cache.encryption.key.error", exception.getMessage())); + } + @Override public void onBackPressed() { if (inErrorState) { diff --git a/app/src/org/commcare/activities/QueryRequestActivity.java b/app/src/org/commcare/activities/QueryRequestActivity.java index c82d51958f..c21d660681 100644 --- a/app/src/org/commcare/activities/QueryRequestActivity.java +++ b/app/src/org/commcare/activities/QueryRequestActivity.java @@ -23,6 +23,7 @@ import org.commcare.session.RemoteQuerySessionManager; import org.commcare.tasks.ModernHttpTask; import org.commcare.tasks.templates.CommCareTaskConnector; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.utils.SessionRegistrationHelper; import org.commcare.utils.SessionUnavailableException; import org.commcare.views.UserfacingErrorHandling; @@ -196,6 +197,11 @@ public void handleIOException(IOException exception) { } } + @Override + public void handleEncryptionKeyException(EncryptionKeyHelper.EncryptionKeyException exception) { + enterErrorState(Localization.get("post.cache.encryption.key.error", exception.getMessage())); + } + @Override public CustomProgressDialog generateProgressDialog(int taskId) { String title, message; diff --git a/app/src/org/commcare/android/database/global/models/AndroidSharedKeyRecord.java b/app/src/org/commcare/android/database/global/models/AndroidSharedKeyRecord.java index ac9c628e78..1487090e20 100755 --- a/app/src/org/commcare/android/database/global/models/AndroidSharedKeyRecord.java +++ b/app/src/org/commcare/android/database/global/models/AndroidSharedKeyRecord.java @@ -9,14 +9,12 @@ import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import org.commcare.util.EncryptionKeyHelper; import org.javarosa.core.services.Logger; import org.javarosa.core.util.PropertyUtils; import java.security.GeneralSecurityException; import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; /** * This is a record of a key that CommCare ODK has shared with another app @@ -51,19 +49,12 @@ public AndroidSharedKeyRecord(String keyId, byte[] privateKey, byte[] publicKey) this.publicKey = publicKey; } - public static AndroidSharedKeyRecord generateNewSharingKey() { - try { - KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(512, new SecureRandom()); - KeyPair pair = generator.genKeyPair(); - byte[] encodedPrivate = pair.getPrivate().getEncoded(); - String privateEncoding = pair.getPrivate().getFormat(); - byte[] encodedPublic = pair.getPublic().getEncoded(); - String publicencoding = pair.getPublic().getFormat(); - return new AndroidSharedKeyRecord(PropertyUtils.genUUID(), pair.getPrivate().getEncoded(), pair.getPublic().getEncoded()); - } catch (NoSuchAlgorithmException nsae) { - return null; - } + public static AndroidSharedKeyRecord generateNewSharingKey() + throws EncryptionKeyHelper.EncryptionKeyException { + KeyPair pair = CryptUtil.generateRandomKeyPair(512); + byte[] encodedPrivate = pair.getPrivate().getEncoded(); + byte[] encodedPublic = pair.getPublic().getEncoded(); + return new AndroidSharedKeyRecord(PropertyUtils.genUUID(), encodedPrivate, encodedPublic); } private String getKeyId() { diff --git a/app/src/org/commcare/android/nfc/NfcManager.java b/app/src/org/commcare/android/nfc/NfcManager.java index ca049329bb..9d0235c2f3 100644 --- a/app/src/org/commcare/android/nfc/NfcManager.java +++ b/app/src/org/commcare/android/nfc/NfcManager.java @@ -1,14 +1,13 @@ package org.commcare.android.nfc; -import android.annotation.TargetApi; import android.app.PendingIntent; import android.content.Context; import android.content.IntentFilter; import android.nfc.NfcAdapter; -import android.os.Build; import org.apache.commons.lang3.StringUtils; -import org.commcare.util.EncryptionUtils; +import org.commcare.util.EncryptionHelper; +import org.commcare.util.EncryptionKeyHelper; import javax.annotation.Nullable; @@ -55,12 +54,13 @@ public void disableForegroundDispatch(AppCompatActivity activity) { } } - public String decryptValue(String message) throws EncryptionUtils.EncryptionException { + public String decryptValue(String message) + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { String payloadTag = getPayloadTag(); if (message.startsWith(payloadTag)) { message = message.replace(payloadTag, ""); if (!StringUtils.isEmpty(encryptionKey)) { - message = EncryptionUtils.decrypt(message, encryptionKey); + message = EncryptionHelper.decryptWithEncodedKey(message, encryptionKey); } } else if (!allowUntaggedRead && !isEmptyPayloadTag(payloadTag)) { throw new InvalidPayloadTagException(); @@ -91,13 +91,14 @@ private boolean isEmptyPayloadTag(String payloadTag) { return payloadTag.contentEquals(getEmptyPayloadTag()); } - public String tagAndEncryptPayload(String message) throws EncryptionUtils.EncryptionException { + public String tagAndEncryptPayload(String message) + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { if (StringUtils.isEmpty(message)) { return message; } String payload = message; if (!StringUtils.isEmpty(encryptionKey)) { - payload = EncryptionUtils.encrypt(payload, encryptionKey); + payload = EncryptionHelper.encryptWithEncodedKey(payload, encryptionKey); } if (payload.contains(PAYLOAD_DELIMITER)) { throw new InvalidPayloadException(); diff --git a/app/src/org/commcare/android/nfc/NfcReadActivity.java b/app/src/org/commcare/android/nfc/NfcReadActivity.java index 888266c2ff..aee3437aed 100644 --- a/app/src/org/commcare/android/nfc/NfcReadActivity.java +++ b/app/src/org/commcare/android/nfc/NfcReadActivity.java @@ -1,18 +1,17 @@ package org.commcare.android.nfc; -import android.annotation.TargetApi; import android.content.Intent; import android.nfc.FormatException; import android.nfc.NdefMessage; import android.nfc.NdefRecord; import android.nfc.Tag; import android.nfc.tech.Ndef; -import android.os.Build; import android.os.Bundle; import android.util.Pair; import org.commcare.android.javarosa.IntentCallout; -import org.commcare.util.EncryptionUtils; +import org.commcare.util.EncryptionHelper; +import org.commcare.util.EncryptionKeyHelper; import java.io.IOException; @@ -95,11 +94,13 @@ private void readFromNfcTag(Tag tag) { finishWithErrorToast("nfc.read.io.error", e); } catch (FormatException e) { finishWithErrorToast("nfc.read.msg.malformed", e); - } catch (EncryptionUtils.EncryptionException e) { + } catch (EncryptionHelper.EncryptionException e) { finishWithErrorToast("nfc.read.msg.decryption.error", e); } catch (NfcManager.InvalidPayloadTagException e) { // payload doesn't have our tag attached, so we should not let the app read this message finishWithErrorToast("nfc.read.msg.payload.tag.error"); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { + finishWithErrorToast("nfc.read.msg.decryption.key.error", e); } finally { try { ndefObject.close(); diff --git a/app/src/org/commcare/android/nfc/NfcWriteActivity.java b/app/src/org/commcare/android/nfc/NfcWriteActivity.java index 44d5dd1659..d27712cf7e 100644 --- a/app/src/org/commcare/android/nfc/NfcWriteActivity.java +++ b/app/src/org/commcare/android/nfc/NfcWriteActivity.java @@ -1,17 +1,16 @@ package org.commcare.android.nfc; -import android.annotation.TargetApi; import android.content.Intent; import android.nfc.FormatException; import android.nfc.NdefMessage; import android.nfc.NdefRecord; import android.nfc.Tag; import android.nfc.tech.Ndef; -import android.os.Build; import android.os.Bundle; import org.commcare.android.javarosa.IntentCallout; -import org.commcare.util.EncryptionUtils; +import org.commcare.util.EncryptionHelper; +import org.commcare.util.EncryptionKeyHelper; import java.io.IOException; @@ -42,10 +41,12 @@ protected void initFields() { typeForPayload = getIntent().getStringExtra(NFC_PAYLOAD_SINGLE_TYPE_ARG); try { payloadToWrite = nfcManager.tagAndEncryptPayload(getIntent().getStringExtra(NFC_PAYLOAD_TO_WRITE)); - } catch (EncryptionUtils.EncryptionException e) { + } catch (EncryptionHelper.EncryptionException e) { finishWithErrorToast("nfc.write.encryption.error", e); } catch (NfcManager.InvalidPayloadException e) { finishWithErrorToast("nfc.write.payload.error", e); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { + finishWithErrorToast("nfc.write.encryption.error", e); } } diff --git a/app/src/org/commcare/models/database/user/DemoUserBuilder.java b/app/src/org/commcare/models/database/user/DemoUserBuilder.java index 56e0304d8f..f90cc69317 100644 --- a/app/src/org/commcare/models/database/user/DemoUserBuilder.java +++ b/app/src/org/commcare/models/database/user/DemoUserBuilder.java @@ -11,6 +11,7 @@ import org.commcare.android.database.app.models.UserKeyRecord; import org.commcare.core.encryption.CryptUtil; import org.commcare.models.encryption.ByteEncrypter; +import org.commcare.util.EncryptionKeyHelper; import org.javarosa.core.model.User; import org.javarosa.core.util.PropertyUtils; @@ -65,8 +66,11 @@ private void createAndWriteKeyRecordAndUser() { int userCount = keyRecordDB.getIDsForValue(UserKeyRecord.META_USERNAME, username).size(); if (userCount == 0) { - SecretKey secretKey = CryptUtil.generateSemiRandomKey(); - if (secretKey == null) { + + SecretKey secretKey; + try { + secretKey = CryptUtil.generateRandomSecretKey(); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { throw new RuntimeException("Error setting up user's encrypted storage"); } randomKey = secretKey.getEncoded(); diff --git a/app/src/org/commcare/network/GetAndParseActor.java b/app/src/org/commcare/network/GetAndParseActor.java index 7f23ab1958..f82948e87d 100644 --- a/app/src/org/commcare/network/GetAndParseActor.java +++ b/app/src/org/commcare/network/GetAndParseActor.java @@ -12,6 +12,7 @@ import org.commcare.core.network.AuthInfo; import org.commcare.core.network.AuthenticationInterceptor; import org.commcare.core.network.ModernHttpRequester; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.util.LogTypes; import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.services.Logger; @@ -108,6 +109,13 @@ public void handleIOException(IOException exception) { } } + @Override + public void handleEncryptionKeyException(EncryptionKeyHelper.EncryptionKeyException exception) { + Logger.log(LogTypes.TYPE_ERROR_ENCRYPTION_KEY, + String.format("Encountered EncryptionKeyException while getting response stream for %s response: %s", + requestName, exception.getMessage())); + } + private void processErrorResponse(int responseCode) { Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, String.format("Received error response from %s request: %s", requestName, responseCode)); diff --git a/app/src/org/commcare/network/HttpCalloutTask.java b/app/src/org/commcare/network/HttpCalloutTask.java index 4592aaeddb..cb4d21e543 100755 --- a/app/src/org/commcare/network/HttpCalloutTask.java +++ b/app/src/org/commcare/network/HttpCalloutTask.java @@ -10,6 +10,7 @@ import org.commcare.data.xml.DataModelPullParser; import org.commcare.data.xml.TransactionParserFactory; import org.commcare.tasks.templates.CommCareTask; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.util.LogTypes; import org.commcare.utils.AndroidCacheDirSetup; import org.javarosa.core.io.StreamsUtil; @@ -43,7 +44,7 @@ public enum HttpCalloutOutcomes { NetworkFailureBadPassword, IncorrectPin, AuthOverHttp, - CaptivePortal + ResponseCacheError, CaptivePortal } private final Context c; @@ -99,6 +100,9 @@ protected HttpCalloutOutcomes doTaskBackground(Object... params) { //This is probably related to local files, actually e.printStackTrace(); outcome = HttpCalloutOutcomes.NetworkFailure; + } catch (EncryptionKeyHelper.EncryptionKeyException e) { + e.printStackTrace(); + outcome = HttpCalloutOutcomes.ResponseCacheError; } //If we needed the callout to succeed and it didn't, return our failure. @@ -131,7 +135,8 @@ protected HttpCalloutOutcomes doSetupTaskBeforeRequest() { protected abstract Response doHttpRequest() throws IOException; - protected HttpCalloutOutcomes doResponseSuccess(Response response) throws IOException { + protected HttpCalloutOutcomes doResponseSuccess(Response response) + throws IOException, EncryptionKeyHelper.EncryptionKeyException { beginResponseHandling(response); InputStream input = cacheResponseOpenHandle(response); @@ -162,7 +167,8 @@ protected HttpCalloutOutcomes doResponseSuccess(Response response) protected abstract TransactionParserFactory getTransactionParserFactory(); - protected InputStream cacheResponseOpenHandle(Response response) throws IOException { + protected InputStream cacheResponseOpenHandle(Response response) + throws IOException, EncryptionKeyHelper.EncryptionKeyException { long dataSizeGuess = ModernHttpRequester.getContentLength(response); BitCache cache = BitCacheFactory.getCache(new AndroidCacheDirSetup(c), dataSizeGuess); diff --git a/app/src/org/commcare/network/RemoteDataPullResponse.java b/app/src/org/commcare/network/RemoteDataPullResponse.java index 6981596917..c6f0336581 100644 --- a/app/src/org/commcare/network/RemoteDataPullResponse.java +++ b/app/src/org/commcare/network/RemoteDataPullResponse.java @@ -7,6 +7,7 @@ import org.commcare.tasks.DataPullTask; import org.commcare.core.network.bitcache.BitCache; import org.commcare.core.network.bitcache.BitCacheFactory; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.utils.AndroidCacheDirSetup; import org.javarosa.core.io.StreamsUtil; @@ -49,7 +50,8 @@ protected RemoteDataPullResponse(DataPullTask task, * * @throws IOException If there is an issue reading or writing the response. */ - public BitCache writeResponseToCache(Context c) throws IOException { + public BitCache writeResponseToCache(Context c) + throws IOException, EncryptionKeyHelper.EncryptionKeyException { BitCache cache = null; try (InputStream input = getInputStream()) { final long dataSizeGuess = ModernHttpRequester.getContentLength(response); diff --git a/app/src/org/commcare/tasks/AsyncRestoreHelper.java b/app/src/org/commcare/tasks/AsyncRestoreHelper.java index 555c33ac2f..19299907a7 100644 --- a/app/src/org/commcare/tasks/AsyncRestoreHelper.java +++ b/app/src/org/commcare/tasks/AsyncRestoreHelper.java @@ -1,6 +1,7 @@ package org.commcare.tasks; import org.commcare.network.RemoteDataPullResponse; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.util.LogTypes; import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.services.Logger; @@ -74,7 +75,7 @@ private boolean parseProgressFromRetryResult(RemoteDataPullResponse response) { } eventType = parser.next(); } while (eventType != KXmlParser.END_DOCUMENT); - } catch (IOException | XmlPullParserException e) { + } catch (IOException | XmlPullParserException | EncryptionKeyHelper.EncryptionKeyException e) { Logger.log(LogTypes.TYPE_USER, "Error while parsing progress values of retry result"); } finally { diff --git a/app/src/org/commcare/tasks/DataPullTask.java b/app/src/org/commcare/tasks/DataPullTask.java index 6dbe8c1e22..57a8257f67 100644 --- a/app/src/org/commcare/tasks/DataPullTask.java +++ b/app/src/org/commcare/tasks/DataPullTask.java @@ -34,6 +34,7 @@ import org.commcare.services.CommCareSessionService; import org.commcare.sync.ExternalDataUpdateHelper; import org.commcare.tasks.templates.CommCareTask; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.util.LogTypes; import org.commcare.utils.FormSaveUtil; import org.commcare.utils.SessionUnavailableException; @@ -223,8 +224,10 @@ private byte[] getEncryptionKey() { private void initUKRForLogin() { if (blockRemoteKeyManagement || shouldGenerateFirstKey()) { - SecretKey newKey = CryptUtil.generateSemiRandomKey(); - if (newKey == null) { + SecretKey newKey = null; + try { + newKey = CryptUtil.generateRandomSecretKey(); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { return; } String sandboxId = PropertyUtils.genUUID().replace("-", ""); @@ -294,6 +297,9 @@ private ResultAndError getRequestResultOrRetry(AndroidTransactio } catch (UnknownSyncError e) { e.printStackTrace(); Logger.log(LogTypes.TYPE_WARNING_NETWORK, "Couldn't sync due to Unknown Error|" + e.getMessage()); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { + e.printStackTrace(); + Logger.log(LogTypes.TYPE_WARNING_NETWORK, "Couldn't sync due to Cache encryption Error|" + e.getMessage()); } wipeLoginIfItOccurred(); @@ -305,8 +311,9 @@ private ResultAndError getRequestResultOrRetry(AndroidTransactio * @return the proper result, or null if we have not yet been able to determine the result to * return */ - private ResultAndError makeRequestAndHandleResponse(AndroidTransactionParserFactory factory) - throws IOException, UnknownSyncError { + private ResultAndError makeRequestAndHandleResponse( + AndroidTransactionParserFactory factory) + throws IOException, UnknownSyncError, EncryptionKeyHelper.EncryptionKeyException { RemoteDataPullResponse pullResponse = dataPullRequester.makeDataPullRequest(this, requestor, server, !loginNeeded, skipFixtures); @@ -352,7 +359,7 @@ private ResultAndError handleAuthFailed() { */ private ResultAndError handleSuccessResponseCode( RemoteDataPullResponse pullResponse, AndroidTransactionParserFactory factory) - throws IOException, UnknownSyncError { + throws IOException, UnknownSyncError, EncryptionKeyHelper.EncryptionKeyException { asyncRestoreHelper.completeServerProgressBarIfShowing(); handleLoginNeededOnSuccess(); @@ -541,6 +548,10 @@ private Pair recover(CommcareRequestEndpoints requestor, Androi //Ok, well, we're bailing here, but we didn't make any changes Logger.log(LogTypes.TYPE_USER, "Sync Recovery Failed due to IOException|" + e.getMessage()); return new Pair<>(PROGRESS_RECOVERY_FAIL_SAFE, ""); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { + e.printStackTrace(); + Logger.log(LogTypes.TYPE_USER, "Sync Recovery Failed due to Cache encryption error|" + e.getMessage()); + return new Pair<>(PROGRESS_RECOVERY_FAIL_SAFE, ""); } this.publishProgress(PROGRESS_RECOVERY_STARTED); diff --git a/app/src/org/commcare/tasks/ModernHttpTask.java b/app/src/org/commcare/tasks/ModernHttpTask.java index 20d87623da..ce8cf425ab 100644 --- a/app/src/org/commcare/tasks/ModernHttpTask.java +++ b/app/src/org/commcare/tasks/ModernHttpTask.java @@ -12,9 +12,11 @@ import org.commcare.core.network.HTTPMethod; import org.commcare.core.network.ModernHttpRequester; import org.commcare.tasks.templates.CommCareTask; +import org.commcare.util.EncryptionKeyHelper; import java.io.IOException; import java.io.InputStream; +import java.security.Key; import java.util.HashMap; import javax.annotation.Nullable; @@ -36,7 +38,7 @@ public class ModernHttpTask private final ModernHttpRequester requester; private InputStream responseDataStream; - private IOException mException; + private Exception mException; private Response mResponse; // Use for GET request @@ -72,7 +74,7 @@ protected Void doTaskBackground(Void... params) { if (mResponse.isSuccessful()) { responseDataStream = requester.getResponseStream(mResponse); } - } catch (IOException e) { + } catch (IOException | EncryptionKeyHelper.EncryptionKeyException e) { mException = e; } return null; @@ -83,7 +85,11 @@ protected void deliverResult(HttpResponseProcessor httpResponseProcessor, Void result) { if (mException != null) { - httpResponseProcessor.handleIOException(mException); + if (mException instanceof IOException ioExcep) { + httpResponseProcessor.handleIOException(ioExcep); + } else if (mException instanceof EncryptionKeyHelper.EncryptionKeyException encryptKeyExcep) { + httpResponseProcessor.handleEncryptionKeyException(encryptKeyExcep); + } } else { // route to appropriate callback based on http response code ModernHttpRequester.processResponse( diff --git a/app/src/org/commcare/utils/EncryptionUtils.java b/app/src/org/commcare/utils/EncryptionUtils.java index dc620c308e..b975f8db6a 100644 --- a/app/src/org/commcare/utils/EncryptionUtils.java +++ b/app/src/org/commcare/utils/EncryptionUtils.java @@ -1,7 +1,6 @@ package org.commcare.utils; import org.commcare.util.Base64; - import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; diff --git a/app/src/org/commcare/utils/GlobalConstants.java b/app/src/org/commcare/utils/GlobalConstants.java index 7b8320f568..01438309e6 100644 --- a/app/src/org/commcare/utils/GlobalConstants.java +++ b/app/src/org/commcare/utils/GlobalConstants.java @@ -56,4 +56,6 @@ public class GlobalConstants { public static final String TRUSTED_SOURCE_PUBLIC_KEY = BuildConfig.TRUSTED_SOURCE_PUBLIC_KEY; public static final String SMS_INSTALL_KEY_STRING = "[commcare app - do not delete]"; + + public static final String KEYSTORE_NAME = "AndroidKeyStore"; } diff --git a/app/src/org/commcare/utils/KeyStoreEncryptionKeyProvider.java b/app/src/org/commcare/utils/KeyStoreEncryptionKeyProvider.java new file mode 100644 index 0000000000..58cfb8ccd4 --- /dev/null +++ b/app/src/org/commcare/utils/KeyStoreEncryptionKeyProvider.java @@ -0,0 +1,110 @@ +package org.commcare.utils; + +import android.os.Build; +import android.security.KeyPairGeneratorSpec; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import org.commcare.CommCareApplication; +import org.commcare.util.EncryptionHelper; +import org.commcare.util.EncryptionKeyHelper; +import org.commcare.util.IKeyStoreEncryptionKeyProvider; + +import java.math.BigInteger; +import java.security.InvalidAlgorithmParameterException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.util.GregorianCalendar; + +import javax.crypto.KeyGenerator; +import javax.security.auth.x500.X500Principal; + +import androidx.annotation.RequiresApi; + +import static org.commcare.util.EncryptionKeyHelper.CC_KEY_ALGORITHM_RSA; + +/** + * Class for providing encryption keys backed by Android Keystore + * + * @author dviggiano + */ +public class KeyStoreEncryptionKeyProvider implements IKeyStoreEncryptionKeyProvider { + + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String ALGORITHM = KeyProperties.KEY_ALGORITHM_AES; + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM; + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String PADDING = KeyProperties.ENCRYPTION_PADDING_NONE; + + // Generates a cryptrographic key and adds it to the Android KeyStore + @Override + public Key generateCryptographicKeyInKeyStore(String keyAlias, + EncryptionHelper.CryptographicOperation cryptographicOperation) + throws EncryptionKeyHelper.EncryptionKeyException{ + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + KeyGenerator keyGenerator = KeyGenerator + .getInstance(KeyProperties.KEY_ALGORITHM_AES, getKeyStoreName()); + KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder(keyAlias, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .build(); + keyGenerator.init(keyGenParameterSpec); + return keyGenerator.generateKey(); + } else { + // Because KeyGenParameterSpec was introduced in Android SDK 23, prior versions + // need to resource to KeyPairGenerator which only generates asymmetric keys, + // hence the need to switch to a correspondent algorithm as well, RSA + // TODO: Add link to StackOverflow page + KeyPairGenerator keyGenerator = KeyPairGenerator + .getInstance(CC_KEY_ALGORITHM_RSA, getKeyStoreName()); + GregorianCalendar start = new GregorianCalendar(); + GregorianCalendar end = new GregorianCalendar(); + end.add(GregorianCalendar.YEAR, 100); + + KeyPairGeneratorSpec keySpec = new KeyPairGeneratorSpec.Builder(CommCareApplication.instance()) + // Key alias to be used to retrieve it from the KeyStore + .setAlias(keyAlias) + // The subject used for the self-signed certificate of the generated pair + .setSubject(new X500Principal(String.format("CN=%s", keyAlias))) + // The serial number used for the self-signed certificate of the + // generated pair + .setSerialNumber(BigInteger.valueOf(1337)) + // Date range of validity for the generated pair + .setStartDate(start.getTime()) + .setEndDate(end.getTime()) + .build(); + + keyGenerator.initialize(keySpec); + KeyPair keyPair = keyGenerator.generateKeyPair(); + if (cryptographicOperation == EncryptionHelper.CryptographicOperation.Encryption) { + return keyPair.getPublic(); + } else { + return keyPair.getPrivate(); + } + } + } catch (NoSuchAlgorithmException | NoSuchProviderException | + InvalidAlgorithmParameterException e) { + throw new EncryptionKeyHelper.EncryptionKeyException("Key generation failed: ", e); + } + } + + @Override + public String getTransformationString() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return String.format("%s/%s/%s", ALGORITHM, BLOCK_MODE, PADDING); + } else { + return "RSA/ECB/PKCS1Padding"; + } + } + + @Override + public String getKeyStoreName() { + return GlobalConstants.KEYSTORE_NAME; + } +} diff --git a/app/src/org/commcare/utils/TemplatePrinterUtils.java b/app/src/org/commcare/utils/TemplatePrinterUtils.java index 910b5b06d2..359bdb0c3a 100755 --- a/app/src/org/commcare/utils/TemplatePrinterUtils.java +++ b/app/src/org/commcare/utils/TemplatePrinterUtils.java @@ -6,6 +6,7 @@ import org.commcare.android.javarosa.IntentCallout; import org.commcare.core.encryption.CryptUtil; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.views.dialogs.StandardAlertDialog; import java.io.BufferedReader; @@ -38,7 +39,18 @@ public abstract class TemplatePrinterUtils { private static final String FORMAT_REGEX_WITH_DELIMITER = "((?<=%2$s)|(?=%1$s))"; - private static final SecretKey KEY = CryptUtil.generateSemiRandomKey(); + private static final SecretKey KEY; + + static { + SecretKey secretKey = null; + try { + secretKey = CryptUtil.generateRandomSecretKey(); + } catch (EncryptionKeyHelper.EncryptionKeyException e) { + secretKey = null; + } finally{ + KEY = secretKey; + } + } /** * Concatenate all Strings in a String array to one String. diff --git a/app/unit-tests/resources/META-INF/services/org.commcare.util.IKeyStoreEncryptionKeyProvider b/app/unit-tests/resources/META-INF/services/org.commcare.util.IKeyStoreEncryptionKeyProvider new file mode 100644 index 0000000000..c4f65b558c --- /dev/null +++ b/app/unit-tests/resources/META-INF/services/org.commcare.util.IKeyStoreEncryptionKeyProvider @@ -0,0 +1 @@ +org.commcare.utils.TestKeyStoreEncryptionKeyProvider \ No newline at end of file diff --git a/app/unit-tests/src/org/commcare/CommCareTestApplication.java b/app/unit-tests/src/org/commcare/CommCareTestApplication.java index eb38ef32cb..70240c0446 100644 --- a/app/unit-tests/src/org/commcare/CommCareTestApplication.java +++ b/app/unit-tests/src/org/commcare/CommCareTestApplication.java @@ -26,6 +26,7 @@ import org.commcare.network.DataPullRequester; import org.commcare.network.LocalReferencePullResponseFactory; import org.commcare.services.CommCareSessionService; +import org.commcare.util.EncryptionKeyHelper; import org.commcare.utils.AndroidCacheDirSetup; import org.javarosa.core.model.User; import org.javarosa.core.reference.ReferenceManager; @@ -211,7 +212,11 @@ public void startUserSession(byte[] symetricKey, UserKeyRecord record, boolean r } if (user != null) { user.setCachedPwd(cachedUserPassword); - user.setWrappedKey(ByteEncrypter.wrapByteArrayWithString(CryptUtil.generateSemiRandomKey().getEncoded(), cachedUserPassword)); + try { + user.setWrappedKey(ByteEncrypter.wrapByteArrayWithString(CryptUtil.generateRandomSecretKey().getEncoded(), cachedUserPassword)); + } catch (EncryptionKeyHelper.EncryptionKeyException e){ + throw new RuntimeException(e); + } } ccService.startSession(user, record); CommCareApplication.instance().setTestingService(ccService); diff --git a/app/unit-tests/src/org/commcare/android/nfc/NfcManagerTest.java b/app/unit-tests/src/org/commcare/android/nfc/NfcManagerTest.java index e64dd0eca4..897f698ccf 100644 --- a/app/unit-tests/src/org/commcare/android/nfc/NfcManagerTest.java +++ b/app/unit-tests/src/org/commcare/android/nfc/NfcManagerTest.java @@ -1,7 +1,8 @@ package org.commcare.android.nfc; import org.commcare.CommCareTestApplication; -import org.commcare.util.EncryptionUtils; +import org.commcare.util.EncryptionHelper; +import org.commcare.util.EncryptionKeyHelper; import org.junit.Test; import static org.commcare.android.nfc.NfcManager.NFC_ENCRYPTION_SCHEME; @@ -14,7 +15,8 @@ public class NfcManagerTest { private static final String PAYLOAD = "dummy_payload"; @Test - public void payloadEncryptionTest() throws EncryptionUtils.EncryptionException { + public void payloadEncryptionTest() + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { NfcManager nfcManager = new NfcManager(CommCareTestApplication.instance(), ENCRYPTION_KEY, ENTITY_ID, false); // Empty payload should not have any tag attached @@ -29,7 +31,8 @@ public void payloadEncryptionTest() throws EncryptionUtils.EncryptionException { } @Test - public void emptyEncryptionKeyTest() throws EncryptionUtils.EncryptionException { + public void emptyEncryptionKeyTest() + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { NfcManager nfcManager = new NfcManager(CommCareTestApplication.instance(), "", ENTITY_ID, false); String encryptedMessage = nfcManager.tagAndEncryptPayload(PAYLOAD); assert encryptedMessage.startsWith(PAYLOAD_DELIMITER + ENTITY_ID + PAYLOAD_DELIMITER); @@ -37,7 +40,8 @@ public void emptyEncryptionKeyTest() throws EncryptionUtils.EncryptionException } @Test - public void emptyEntityIdTest() throws EncryptionUtils.EncryptionException { + public void emptyEntityIdTest() + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { NfcManager nfcManager = new NfcManager(CommCareTestApplication.instance(), ENCRYPTION_KEY, "", false); String encryptedMessage = nfcManager.tagAndEncryptPayload(PAYLOAD); assert encryptedMessage.startsWith(NFC_ENCRYPTION_SCHEME + PAYLOAD_DELIMITER + PAYLOAD_DELIMITER); @@ -45,7 +49,8 @@ public void emptyEntityIdTest() throws EncryptionUtils.EncryptionException { } @Test - public void emptyEncryptionKeyAndEntityIdTest() throws EncryptionUtils.EncryptionException { + public void emptyEncryptionKeyAndEntityIdTest() + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { NfcManager nfcManager = new NfcManager(CommCareTestApplication.instance(), "", "", false); String encryptedMessage = nfcManager.tagAndEncryptPayload(PAYLOAD); assert encryptedMessage.startsWith(PAYLOAD_DELIMITER + PAYLOAD_DELIMITER); @@ -53,7 +58,8 @@ public void emptyEncryptionKeyAndEntityIdTest() throws EncryptionUtils.Encryptio } @Test(expected = NfcManager.InvalidPayloadTagException.class) - public void readingPayloadWithDifferentTag_shouldFail() throws EncryptionUtils.EncryptionException { + public void readingPayloadWithDifferentTag_shouldFail() + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { NfcManager nfcManager = new NfcManager(CommCareTestApplication.instance(), ENCRYPTION_KEY, "some_other_id", false); String encryptedMessage = nfcManager.tagAndEncryptPayload(PAYLOAD); new NfcManager(CommCareTestApplication.instance(), ENCRYPTION_KEY, ENTITY_ID, false) @@ -61,7 +67,8 @@ public void readingPayloadWithDifferentTag_shouldFail() throws EncryptionUtils.E } @Test - public void payloadWithoutTagTest() throws EncryptionUtils.EncryptionException { + public void payloadWithoutTagTest() + throws EncryptionHelper.EncryptionException, EncryptionKeyHelper.EncryptionKeyException { // Decrypt an old payload without specifying encryptionKey and entityId assert new NfcManager(CommCareTestApplication.instance(), "", "", false) .decryptValue(PAYLOAD).contentEquals(PAYLOAD); @@ -71,4 +78,4 @@ assert new NfcManager(CommCareTestApplication.instance(), ENCRYPTION_KEY, ENTITY .decryptValue(PAYLOAD).contentEquals(PAYLOAD); } -} +} \ No newline at end of file diff --git a/app/unit-tests/src/org/commcare/utils/EncryptCredentialsInMemoryTest.kt b/app/unit-tests/src/org/commcare/utils/EncryptCredentialsInMemoryTest.kt new file mode 100644 index 0000000000..0e01983fa4 --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/EncryptCredentialsInMemoryTest.kt @@ -0,0 +1,95 @@ +package org.commcare.utils + +import android.security.keystore.KeyGenParameterSpec +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every +import io.mockk.mockk +import org.commcare.android.util.TestAppInstaller +import org.commcare.CommCareApplication +import org.commcare.CommCareTestApplication +import org.commcare.util.EncryptionKeyHelper +import org.commcare.util.EncryptionHelper +import org.javarosa.core.model.User +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(application = CommCareTestApplication::class) +@RunWith(AndroidJUnit4::class) +class EncryptCredentialsInMemoryTest { + + @Before + fun setup() { + TestAppInstaller.installAppAndUser( + "jr://resource/commcare-apps/update_tests/base_app/profile.ccpr", + TEST_USER, + TEST_PASS + ) + } + + @Test + fun saveUsernameWithKeyStoreAndReadWithout_shouldPass() { + // confirm that there is no android key store available + + Assert.assertFalse(EncryptionKeyHelper.isKeyStoreAvailable()) + + // register mock Android key store provider, this is when the key store becomes available + MockAndroidKeyStoreProvider.registerProvider() + + // generate key to encrypt User credentials in the key store + generateUserCredentialKey() + + // assert that the android key store is available + Assert.assertTrue(EncryptionKeyHelper.isKeyStoreAvailable()) + + // login with the Android key store available + TestAppInstaller.login(TEST_USER, TEST_PASS) + + // retrieve the logged in user, this should be using the encrypted version + var user = CommCareApplication.instance().session.loggedInUser + + // save the same username and store the username for future comparison + user.setUsername(TEST_USER) + CommCareApplication.instance().getRawStorage( + "USER", + User::class.java, + CommCareApplication.instance().userDbHandle + ).write(user) + val username = user.username + + // close the user session + CommCareApplication.instance().closeUserSession() + + // deregister the mock Android key store provider, key store is no longer available + MockAndroidKeyStoreProvider.deregisterProvider() + + // confirm that the key store is no longer available + Assert.assertFalse(EncryptionKeyHelper.isKeyStoreAvailable()) + + // login once again, this time without the keystore + TestAppInstaller.login(TEST_USER, TEST_PASS) + + // retrieve the current logged in user + user = CommCareApplication.instance().session.loggedInUser + + // confirm that the previously captured username matches the current user's + Assert.assertEquals(username, user.username) + } + + private fun generateUserCredentialKey() { + val mockKeyGenParameterSpec = mockk() + every { mockKeyGenParameterSpec.keystoreAlias } returns EncryptionKeyHelper.CC_IN_MEMORY_ENCRYPTION_KEY_ALIAS + + // generate key using mock key generator + val mockKeyGenerator = MockKeyGenerator() + mockKeyGenerator.init(mockKeyGenParameterSpec) + mockKeyGenerator.generateKey() + } + + companion object { + private const val TEST_USER = "test" + private const val TEST_PASS = "123" + } +} diff --git a/app/unit-tests/src/org/commcare/utils/MockAndroidKeyStoreProvider.java b/app/unit-tests/src/org/commcare/utils/MockAndroidKeyStoreProvider.java new file mode 100644 index 0000000000..8478e3aa4e --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/MockAndroidKeyStoreProvider.java @@ -0,0 +1,25 @@ +package org.commcare.utils; + +import java.security.Provider; +import java.security.Security; + +public class MockAndroidKeyStoreProvider extends Provider { + + { + put("KeyStore.AndroidKeyStore", MockKeyStore.class.getName()); + } + + protected MockAndroidKeyStoreProvider() { + super(GlobalConstants.KEYSTORE_NAME, 1.0, "Mock AndroidKeyStore provider"); + } + + public static void registerProvider() { + Security.addProvider(new MockAndroidKeyStoreProvider()); + } + + public static void deregisterProvider() { + if (Security.getProvider(GlobalConstants.KEYSTORE_NAME) != null) { + Security.removeProvider(GlobalConstants.KEYSTORE_NAME); + } + } +} diff --git a/app/unit-tests/src/org/commcare/utils/MockKeyGenerator.java b/app/unit-tests/src/org/commcare/utils/MockKeyGenerator.java new file mode 100644 index 0000000000..4322c8fc76 --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/MockKeyGenerator.java @@ -0,0 +1,12 @@ +package org.commcare.utils; + +import java.security.Security; + +import javax.crypto.KeyGenerator; + +public class MockKeyGenerator extends KeyGenerator { + + public MockKeyGenerator() { + super(new MockKeyGeneratorSpi() , Security.getProvider(GlobalConstants.KEYSTORE_NAME), "AES"); + } +} diff --git a/app/unit-tests/src/org/commcare/utils/MockKeyGeneratorSpi.java b/app/unit-tests/src/org/commcare/utils/MockKeyGeneratorSpi.java new file mode 100644 index 0000000000..224bf53cce --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/MockKeyGeneratorSpi.java @@ -0,0 +1,65 @@ +package org.commcare.utils; + +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.spec.AlgorithmParameterSpec; + +import javax.crypto.KeyGenerator; +import javax.crypto.KeyGeneratorSpi; +import javax.crypto.SecretKey; + +public class MockKeyGeneratorSpi extends KeyGeneratorSpi { + private final KeyGenerator wrappedKeyGenerator; + private final KeyStore keyStore; + private KeyGenParameterSpec spec = null; + + { + try { + wrappedKeyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES); + keyStore = KeyStore.getInstance(GlobalConstants.KEYSTORE_NAME); + keyStore.load(null); + } catch (CertificateException | IOException | NoSuchAlgorithmException | + KeyStoreException e) { + throw new RuntimeException(e); + } + } + + @Override + protected void engineInit(AlgorithmParameterSpec params, SecureRandom random) + throws InvalidAlgorithmParameterException { + if (!(params instanceof KeyGenParameterSpec)) { + throw new InvalidAlgorithmParameterException( + String.format("Cannot initialize without a %s parameter", KeyGenParameterSpec.class.getName())); + } + spec = (KeyGenParameterSpec)params; + } + + @Override + protected void engineInit(int keysize, SecureRandom random) { + // Do nothing, this is a mock key generator + } + + @Override + protected void engineInit(SecureRandom random) { + // Do nothing, this is a mock key generator + } + + @Override + protected SecretKey engineGenerateKey() { + SecretKey secretKey = wrappedKeyGenerator.generateKey(); + try { + keyStore.setKeyEntry(spec.getKeystoreAlias(), secretKey, null, null); + } catch (KeyStoreException e) { + throw new RuntimeException(e); + } + return secretKey; + } +} diff --git a/app/unit-tests/src/org/commcare/utils/MockKeyStore.java b/app/unit-tests/src/org/commcare/utils/MockKeyStore.java new file mode 100644 index 0000000000..cc54157796 --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/MockKeyStore.java @@ -0,0 +1,125 @@ +package org.commcare.utils; + +import java.io.InputStream; +import java.io.OutputStream; +import java.security.Key; +import java.security.KeyStore; +import java.security.KeyStoreSpi; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.util.Date; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; + +import javax.crypto.SecretKey; + +public class MockKeyStore extends KeyStoreSpi { + + private static final HashMap keys = new HashMap<>(); + private static final HashMap certs = new HashMap<>(); + + @Override + public void engineSetKeyEntry(String alias, Key key, char[] password, Certificate[] chain) { + keys.put(alias, key); + } + + @Override + public void engineDeleteEntry(String alias) { + keys.remove(alias); + certs.remove(alias); + } + + @Override + public boolean engineContainsAlias(String alias) { + return keys.containsKey(alias) || certs.containsKey(alias); + } + + @Override + public int engineSize() { + Set allKeys = new HashSet<>(); + allKeys.addAll(keys.keySet()); + allKeys.addAll(certs.keySet()); + return allKeys.size(); + } + + @Override + public boolean engineIsKeyEntry(String alias) { + return keys.containsKey(alias); + } + + @Override + public boolean engineIsCertificateEntry(String alias) { + return certs.containsKey(alias); + } + + @Override + public KeyStore.Entry engineGetEntry(String alias, KeyStore.ProtectionParameter protParam) { + Key key = keys.get(alias); + if (key != null) { + if (key instanceof SecretKey){ + return new KeyStore.SecretKeyEntry((SecretKey)key); + } else if (key instanceof PrivateKey) { + return new KeyStore.PrivateKeyEntry((PrivateKey)key, null); + } + } + Certificate cert = certs.get(alias); + if (cert != null) { + return new KeyStore.TrustedCertificateEntry(cert); + } + throw new UnsupportedOperationException(String.format("No alias found in keys or certs, alias=%s", alias)); + } + + @Override + public Key engineGetKey(String alias, char[] password) { + return null; + } + + @Override + public Certificate[] engineGetCertificateChain(String alias) { + return null; + } + + @Override + public Certificate engineGetCertificate(String alias) { + return null; + } + + @Override + public Date engineGetCreationDate(String alias) { + return null; + } + + @Override + public String engineGetCertificateAlias(Certificate cert) { + return null; + } + + @Override + public void engineStore(OutputStream stream, char[] password) { + // Do nothing, this is a mock key store + } + + @Override + public void engineLoad(InputStream stream, char[] password) { + // Do nothing, this is a mock key store + } + + @Override + public Enumeration engineAliases() { + return null; + } + + @Override + public void engineSetKeyEntry(String alias, byte[] key, Certificate[] chain) { + // Do nothing, this is a mock key store for secret keys + } + + @Override + public void engineSetCertificateEntry(String alias, Certificate cert) { + // Do nothing, this is a mock key store for secret keys + } +} + + diff --git a/app/unit-tests/src/org/commcare/utils/TestKeyStoreEncryptionKeyProvider.java b/app/unit-tests/src/org/commcare/utils/TestKeyStoreEncryptionKeyProvider.java new file mode 100644 index 0000000000..4387cf4440 --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/TestKeyStoreEncryptionKeyProvider.java @@ -0,0 +1,45 @@ +package org.commcare.utils; + +import android.os.Build; +import android.security.keystore.KeyProperties; +import androidx.annotation.RequiresApi; +import org.commcare.util.EncryptionHelper; +import org.commcare.util.EncryptionKeyHelper; +import org.commcare.util.IKeyStoreEncryptionKeyProvider; +import java.security.Key; + +/** + * Class for providing encryption keys backed by Android Keystore for Unit testing + * + * @author avazirna + */ +public class TestKeyStoreEncryptionKeyProvider implements IKeyStoreEncryptionKeyProvider { + + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String ALGORITHM = KeyProperties.KEY_ALGORITHM_AES; + + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM; + + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String PADDING = KeyProperties.ENCRYPTION_PADDING_NONE; + + // Generates a cryptrographic key and adds it to the Android KeyStore + @Override + public Key generateCryptographicKeyInKeyStore( + String keyAlias, EncryptionHelper.CryptographicOperation cryptographicOperation) + throws EncryptionKeyHelper.EncryptionKeyException { + throw new EncryptionKeyHelper.EncryptionKeyException( + "KeyStore encryption key generator provider for testing only"); + } + + @Override + public String getTransformationString() { + return String.format("%s/%s/%s", ALGORITHM, BLOCK_MODE, PADDING); + } + + @Override + public String getKeyStoreName() { + return "AndroidKeyStore"; + } +}