diff --git a/app/src/org/commcare/activities/FormEntryActivity.java b/app/src/org/commcare/activities/FormEntryActivity.java index 25e0aa0d96..2c719c605b 100644 --- a/app/src/org/commcare/activities/FormEntryActivity.java +++ b/app/src/org/commcare/activities/FormEntryActivity.java @@ -144,6 +144,7 @@ public class FormEntryActivity extends SaveSessionCommCareActivity moveAndScaleImage(File originalImage, boolean if (HiddenPreferences.isMediaCaptureEncryptionEnabled()) { finalFilePath = finalFilePath + MediaWidget.AES_EXTENSION; try { - EncryptionIO.encryptFile(sourcePath, finalFilePath, formEntryActivity.getSymetricKey()); + if (formEntryActivity.isKeyFromKeystore()) { + EncryptionIO.encryptFile(sourcePath, finalFilePath, + CommCareKeyManager.retrieveSessionKeyAndTransformation()); + } else { + EncryptionIO.encryptFile(sourcePath, finalFilePath, formEntryActivity.getSymetricKey(), + null, false); + } } catch (Exception e) { throw new IOException("Failed to encrypt " + sourcePath + " to " + finalFilePath, e); diff --git a/app/src/org/commcare/android/database/user/models/FormRecord.java b/app/src/org/commcare/android/database/user/models/FormRecord.java index 006fa893a7..c41e70fd7e 100755 --- a/app/src/org/commcare/android/database/user/models/FormRecord.java +++ b/app/src/org/commcare/android/database/user/models/FormRecord.java @@ -217,6 +217,10 @@ public byte[] getAesKey() { return aesKey; } + public boolean usesKeystoreEncryption() { + return aesKey == null || aesKey.length == 0; + } + public String getStatus() { return status; } diff --git a/app/src/org/commcare/models/AndroidSessionWrapper.java b/app/src/org/commcare/models/AndroidSessionWrapper.java index 201389c60f..6cc8923cf8 100755 --- a/app/src/org/commcare/models/AndroidSessionWrapper.java +++ b/app/src/org/commcare/models/AndroidSessionWrapper.java @@ -4,6 +4,7 @@ import org.commcare.CommCareApplication; import org.commcare.android.database.user.models.FormRecord; +import org.commcare.services.CommCareKeyManager; import org.commcare.android.database.user.models.SessionStateDescriptor; import org.commcare.models.database.AndroidSandbox; import org.commcare.models.database.InterruptedFormState; @@ -38,7 +39,7 @@ import java.util.Set; import java.util.Vector; -import javax.crypto.SecretKey; + /** * This is a container class which maintains all of the appropriate hooks for managing the details @@ -203,12 +204,10 @@ public void commitStub() { SqlStorage storage = CommCareApplication.instance().getUserStorage(FormRecord.class); SqlStorage sessionStorage = CommCareApplication.instance().getUserStorage(SessionStateDescriptor.class); - SecretKey key = CommCareApplication.instance().createNewSymmetricKey(); - //TODO: this has two components which can fail. be able to roll them back FormRecord r = new FormRecord(FormRecord.STATUS_UNSTARTED, getSession().getForm(), - key.getEncoded(), null, new Date(0), + CommCareKeyManager.generateLegacyKeyOrEmpty(), null, new Date(0), CommCareApplication.instance().getCurrentApp().getAppRecord().getApplicationId()); storage.write(r); setFormRecordId(r.getID()); diff --git a/app/src/org/commcare/models/FormRecordProcessor.java b/app/src/org/commcare/models/FormRecordProcessor.java index 84c8beb4eb..9b97644847 100644 --- a/app/src/org/commcare/models/FormRecordProcessor.java +++ b/app/src/org/commcare/models/FormRecordProcessor.java @@ -16,6 +16,8 @@ import org.commcare.preferences.DeveloperPreferences; import org.commcare.sync.ExternalDataUpdateHelper; import org.commcare.util.LogTypes; +import org.commcare.models.encryption.EncryptionIO; +import org.commcare.services.CommCareKeyManager; import org.commcare.utils.FormUploadUtil; import org.commcare.utils.QuarantineUtil; import org.commcare.views.notifications.NotificationMessage; @@ -29,6 +31,7 @@ import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.util.Date; @@ -70,9 +73,7 @@ public FormRecord process(FormRecord record) final File f = new File(form); - final Cipher decrypter = - FormUploadUtil.getDecryptCipher((new SecretKeySpec(record.getAesKey(), "AES"))); - InputStream is = new CipherInputStream(new FileInputStream(f), decrypter); + InputStream is = getDecryptedInputStream(record, f); AndroidTransactionParserFactory factory = new AndroidTransactionParserFactory(c, null) { @Override @@ -224,8 +225,7 @@ private boolean performLinearFileScan(FormRecord r, File recordFile, boolean use try { //decrypter if (useCipher) { - Cipher decrypter = FormUploadUtil.getDecryptCipher((new SecretKeySpec(r.getAesKey(), "AES"))); - is = new CipherInputStream(new FileInputStream(recordFile), decrypter); + is = getDecryptedInputStream(r, recordFile); } else { is = new FileInputStream(recordFile); } @@ -255,8 +255,7 @@ private boolean attemptXmlScan(FormRecord r, File recordFile, StringBuilder repo KXmlParser parser = new KXmlParser(); InputStream is = null; try { - Cipher decrypter = FormUploadUtil.getDecryptCipher((new SecretKeySpec(r.getAesKey(), "AES"))); - is = new CipherInputStream(new FileInputStream(recordFile), decrypter); + is = getDecryptedInputStream(r, recordFile); parser.setInput(is, "UTF-8"); parser.setFeature(KXmlParser.FEATURE_PROCESS_NAMESPACES, true); @@ -277,4 +276,19 @@ private boolean attemptXmlScan(FormRecord r, File recordFile, StringBuilder repo } } } + + private static InputStream getDecryptedInputStream(FormRecord record, File file) + throws FileNotFoundException { + if (record.usesKeystoreEncryption()) { + return EncryptionIO.getFileInputStreamWithKeystore( + file.getAbsolutePath(), + CommCareKeyManager.retrieveSessionKeyAndTransformation()); + } else { + return EncryptionIO.getFileInputStream( + file.getAbsolutePath(), + new SecretKeySpec(record.getAesKey(), "AES"), + null, + false); + } + } } diff --git a/app/src/org/commcare/models/database/HybridFileBackedSqlStorage.java b/app/src/org/commcare/models/database/HybridFileBackedSqlStorage.java index 421656f423..6ae654a5c7 100644 --- a/app/src/org/commcare/models/database/HybridFileBackedSqlStorage.java +++ b/app/src/org/commcare/models/database/HybridFileBackedSqlStorage.java @@ -166,7 +166,7 @@ private IDatabase getDbOrThrow() { protected InputStream getInputStreamFromFile(String filename, byte[] aesKeyBytes) throws FileNotFoundException { SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES"); - return EncryptionIO.getFileInputStream(filename, aesKey); + return EncryptionIO.getFileInputStream(filename, aesKey, null, false); } @Override diff --git a/app/src/org/commcare/models/encryption/EncryptionIO.java b/app/src/org/commcare/models/encryption/EncryptionIO.java index 2f4a6319be..86156c7ca0 100644 --- a/app/src/org/commcare/models/encryption/EncryptionIO.java +++ b/app/src/org/commcare/models/encryption/EncryptionIO.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.Key; import java.security.NoSuchAlgorithmException; @@ -27,6 +28,7 @@ import javax.crypto.CipherInputStream; import javax.crypto.CipherOutputStream; import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; /** @@ -36,12 +38,27 @@ */ public class EncryptionIO { - public static void encryptFile(String sourceFilePath, String destPath, SecretKeySpec symmetricKey) throws IOException { + public static void encryptFile( + String sourceFilePath, + String destPath, + EncryptionKeyAndTransform encryptionKeyAndTransform + ) throws IOException { + encryptFile(sourceFilePath, destPath, encryptionKeyAndTransform.getKey(), + encryptionKeyAndTransform.getTransformation(), true); + } + + public static void encryptFile( + String sourceFilePath, + String destPath, + Key key, + String transformation, + boolean isKeyFromKeystore + ) throws IOException { Trace trace = CCPerfMonitoring.INSTANCE.startTracing(CCPerfMonitoring.TRACE_FILE_ENCRYPTION_TIME); OutputStream os; FileInputStream is; - os = createFileOutputStream(destPath, symmetricKey); + os = createFileOutputStream(destPath, key, transformation, isKeyFromKeystore); is = new FileInputStream(sourceFilePath); int fileSize = is.available(); StreamsUtil.writeFromInputToOutputNew(is, os); @@ -50,7 +67,7 @@ public static void encryptFile(String sourceFilePath, String destPath, SecretKey trace, fileSize, FilenameUtils.getExtension(sourceFilePath), - false + isKeyFromKeystore ); } @@ -115,15 +132,52 @@ private static OutputStream createFileOutputStream(String filename, } } + public static Cipher getKeystoreDecryptCipher(Key key, String transformation, InputStream is) + throws IOException, NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException { + int ivLength = is.read(); + byte[] iv = new byte[ivLength]; + is.read(iv); + + Cipher cipher = Cipher.getInstance(transformation); + cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv)); + return cipher; + } + + public static Cipher getKeystoreDecryptCipher( + EncryptionKeyAndTransform encryptionKeyAndTransform, + InputStream is + ) throws InvalidAlgorithmParameterException, NoSuchPaddingException, IOException, NoSuchAlgorithmException, + InvalidKeyException { + return getKeystoreDecryptCipher( + encryptionKeyAndTransform.getKey(), + encryptionKeyAndTransform.getTransformation(), + is + ); + } + + public static Cipher getDecryptCipher(Key key) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException { + Cipher cipher = Cipher.getInstance("AES"); + cipher.init(Cipher.DECRYPT_MODE, key); + return cipher; + } + public static InputStream getFileInputStream(String filepath, - SecretKeySpec symetricKey) throws FileNotFoundException { + Key symmetricKey, + String transformation, + boolean isKeyFromAndroidKeyStore + ) throws FileNotFoundException { final File file = new File(filepath); InputStream is; try { is = new FileInputStream(file); - if (symetricKey != null) { - Cipher cipher = Cipher.getInstance("AES"); - cipher.init(Cipher.DECRYPT_MODE, symetricKey); + if (symmetricKey != null) { + Cipher cipher; + if (isKeyFromAndroidKeyStore) { + cipher = getKeystoreDecryptCipher(symmetricKey, transformation, is); + } else { + cipher = getDecryptCipher(symmetricKey); + } is = new BufferedInputStream(new CipherInputStream(is, cipher)); } @@ -137,10 +191,29 @@ public static InputStream getFileInputStream(String filepath, //files are smaller than their contents (padded encryption data, etc), //so you can't actually know that's correct. We should be relying on the //methods we use to read data to make sure it's all coming out. - } catch (InvalidKeyException | NoSuchPaddingException - | NoSuchAlgorithmException e) { + } catch (InvalidKeyException | NoSuchPaddingException | NoSuchAlgorithmException | + InvalidAlgorithmParameterException | IOException e) { e.printStackTrace(); throw new RuntimeException(e); } } + + public static InputStream getFileInputStreamWithKeystore( + String filepath, + EncryptionKeyAndTransform encryptionKeyAndTransform + ) throws FileNotFoundException { + return getFileInputStream(filepath, + encryptionKeyAndTransform.getKey(), + encryptionKeyAndTransform.getTransformation(), + true + ); + } + + public static InputStream getFileInputStreamWithKeystore( + String filepath, + Key key, + String transformation + ) throws FileNotFoundException { + return getFileInputStream(filepath, key, transformation, true); + } } diff --git a/app/src/org/commcare/network/EncryptedFileBody.java b/app/src/org/commcare/network/EncryptedFileBody.java index 607424738a..316448cd4f 100755 --- a/app/src/org/commcare/network/EncryptedFileBody.java +++ b/app/src/org/commcare/network/EncryptedFileBody.java @@ -1,18 +1,24 @@ package org.commcare.network; -import org.commcare.utils.FormUploadUtil; +import org.commcare.models.encryption.EncryptionIO; +import org.commcare.util.LogTypes; import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.io.StreamsUtil.InputIOException; import org.javarosa.core.io.StreamsUtil.OutputIOException; +import org.javarosa.core.services.Logger; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; import java.security.Key; +import java.security.NoSuchAlgorithmException; import javax.annotation.Nullable; import javax.crypto.Cipher; import javax.crypto.CipherInputStream; +import javax.crypto.NoSuchPaddingException; import okhttp3.MediaType; import okhttp3.RequestBody; @@ -50,20 +56,19 @@ public long contentLength() { @Override public void writeTo(BufferedSink sink) throws IOException { FileInputStream fis = new FileInputStream(file); - byte[] iv = null; - if (isKeyFromAndroidKeyStore) { - int ivLength = fis.read() & 0xFF; - iv = new byte[ivLength]; - fis.read(iv, 0, ivLength); - } //The only time this can cause issues is if the body has disappeared since construction. Don't worry about that, since //it'll get caught when we initialize. Cipher cipher; - if (!isKeyFromAndroidKeyStore) { - cipher = FormUploadUtil.getDecryptCipher(key); - } else { - cipher = FormUploadUtil.getDecryptCipher(key, transformation, iv); + try { + if (isKeyFromAndroidKeyStore) { + cipher = EncryptionIO.getKeystoreDecryptCipher(key, transformation, fis); + } else { + cipher = EncryptionIO.getDecryptCipher(key); + } + } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException | InvalidKeyException e) { + Logger.log(LogTypes.TYPE_ERROR_CRYPTO, "Cipher initialization failed: " + e.getMessage()); + throw new RuntimeException(e.getMessage()); } try (CipherInputStream cis = new CipherInputStream(fis, cipher)) { StreamsUtil.writeFromInputToOutputUnmanaged(cis, sink.outputStream()); diff --git a/app/src/org/commcare/sync/FormSubmissionHelper.java b/app/src/org/commcare/sync/FormSubmissionHelper.java index 8ae47375a0..bc4fb473e1 100644 --- a/app/src/org/commcare/sync/FormSubmissionHelper.java +++ b/app/src/org/commcare/sync/FormSubmissionHelper.java @@ -17,6 +17,7 @@ import org.commcare.tasks.DataSubmissionListener; import org.commcare.tasks.FormRecordCleanupTask; import org.commcare.util.LogTypes; +import org.commcare.services.CommCareKeyManager; import org.commcare.utils.FormUploadResult; import org.commcare.utils.FormUploadUtil; import org.commcare.utils.SessionUnavailableException; @@ -315,8 +316,13 @@ private void sendForms(FormRecord[] records) throws TaskCancelledException { throw new TaskCancelledException(); } - mResults[i] = FormUploadUtil.sendInstance(i, folder, - new SecretKeySpec(record.getAesKey(), "AES"), mUrl, this, user); + if (record.usesKeystoreEncryption()) { + mResults[i] = FormUploadUtil.sendInstanceWithKeystore(i, folder, + CommCareKeyManager.retrieveSessionKeyAndTransformation(), mUrl, this, user); + } else { + mResults[i] = FormUploadUtil.sendInstance(i, folder, + new SecretKeySpec(record.getAesKey(), "AES"), mUrl, this, user); + } if (mResults[i] == FormUploadResult.FULL_SUCCESS) { logSubmissionSuccess(record); break; diff --git a/app/src/org/commcare/tasks/DumpTask.java b/app/src/org/commcare/tasks/DumpTask.java index c7532c4109..26c67ff1bb 100644 --- a/app/src/org/commcare/tasks/DumpTask.java +++ b/app/src/org/commcare/tasks/DumpTask.java @@ -9,7 +9,10 @@ import org.commcare.android.database.user.models.FormRecord; import org.commcare.models.database.SqlStorage; import org.commcare.tasks.templates.CommCareTask; +import org.commcare.models.encryption.EncryptionIO; +import org.commcare.services.CommCareKeyManager; import org.commcare.util.LogTypes; +import org.commcare.utils.EncryptionKeyAndTransform; import org.commcare.utils.FileUtil; import org.commcare.utils.FormUploadResult; import org.commcare.utils.FormUploadUtil; @@ -19,6 +22,7 @@ import org.commcare.views.notifications.NotificationMessageFactory; import org.commcare.views.notifications.ProcessIssues; import org.commcare.views.widgets.MediaWidget; +import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.services.Logger; import org.javarosa.core.services.locale.Localization; @@ -26,8 +30,12 @@ import java.io.File; import java.io.FileFilter; import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.io.PrintStream; +import java.security.Key; import java.util.ArrayList; import java.util.Arrays; import java.util.Vector; @@ -68,7 +76,12 @@ protected void onPostExecute(Boolean result) { private static final String[] SUPPORTED_FILE_EXTS = {".xml", ".jpg", ".3gpp", ".3gp"}; - private FormUploadResult dumpInstance(File folder, SecretKeySpec key) throws FileNotFoundException { + private FormUploadResult dumpInstance( + File folder, + Key key, + String transformation, + boolean isKeystoreKey + ) throws FileNotFoundException { Logger.log(TAG, "Dumping form instance at folder: " + folder); @@ -110,20 +123,23 @@ private FormUploadResult dumpInstance(File folder, SecretKeySpec key) throws Fil //this.startSubmission(submissionNumber, bytes); - final Cipher decrypter = FormUploadUtil.getDecryptCipher(key); - /* Encrypted files need to copied to the SD Card in their original form, reason * being the decryption key is associated with to the user and device and therefore * not available in the target device */ for (File file : files) { try { - if (file.getName().endsWith(".xml")) - FileUtil.copyFile(file, new File(myDir, file.getName()), decrypter, null); - else if (file.getName().endsWith(MediaWidget.AES_EXTENSION)) - FileUtil.copyFile(file, new File(myDir, MediaWidget.removeAESExtension(file.getName())), decrypter, null); - else + if (file.getName().endsWith(".xml") || file.getName().endsWith(MediaWidget.AES_EXTENSION)) { + String targetName = file.getName().endsWith(MediaWidget.AES_EXTENSION) + ? MediaWidget.removeAESExtension(file.getName()) : file.getName(); + try (InputStream is = EncryptionIO.getFileInputStream(file.getAbsolutePath(), key, + transformation, isKeystoreKey); + OutputStream os = new FileOutputStream(new File(myDir, targetName))) { + StreamsUtil.writeFromInputToOutputNew(is, os); + } + } else { FileUtil.copyFile(file, new File(myDir, file.getName())); + } } catch (IOException ie) { Logger.log(TAG, "Error copying file: " + file + " exception: " + ie.getMessage()); publishProgress(("File writing failed: " + ie.getMessage())); @@ -133,6 +149,11 @@ else if (file.getName().endsWith(MediaWidget.AES_EXTENSION)) return FormUploadResult.FULL_SUCCESS; } + private FormUploadResult dumpInstanceWithKeystore(File folder, EncryptionKeyAndTransform keystoreKey) + throws FileNotFoundException { + return dumpInstance(folder, keystoreKey.getKey(), keystoreKey.getTransformation(), true); + } + @SuppressLint("NewApi") @Override protected Boolean doTaskBackground(String... params) { @@ -226,7 +247,12 @@ protected Boolean doTaskBackground(String... params) { //Good! //Time to Send! try { - results[i] = dumpInstance(folder, new SecretKeySpec(record.getAesKey(), "AES")); + if (record.usesKeystoreEncryption()) { + results[i] = dumpInstanceWithKeystore(folder, + CommCareKeyManager.retrieveSessionKeyAndTransformation()); + } else { + results[i] = dumpInstance(folder, new SecretKeySpec(record.getAesKey(), "AES"), null, false); + } } catch (FileNotFoundException e) { if (CommCareApplication.instance().isStorageAvailable()) { diff --git a/app/src/org/commcare/tasks/FormLoaderTask.java b/app/src/org/commcare/tasks/FormLoaderTask.java index 1d79e3c25a..dab3f3397b 100644 --- a/app/src/org/commcare/tasks/FormLoaderTask.java +++ b/app/src/org/commcare/tasks/FormLoaderTask.java @@ -260,7 +260,7 @@ private boolean importData(String filePath, FormEntryController fec) { // convert files into a byte array InputStream is; try { - is = EncryptionIO.getFileInputStream(filePath, mSymetricKey); + is = EncryptionIO.getFileInputStream(filePath, mSymetricKey, null, false); } catch (FileNotFoundException e) { e.printStackTrace(); throw new RuntimeException("Unable to open encrypted form instance file: " + filePath); diff --git a/app/src/org/commcare/tasks/FormRecordCleanupTask.java b/app/src/org/commcare/tasks/FormRecordCleanupTask.java index bb36523a69..56d6f71f43 100644 --- a/app/src/org/commcare/tasks/FormRecordCleanupTask.java +++ b/app/src/org/commcare/tasks/FormRecordCleanupTask.java @@ -14,6 +14,7 @@ import org.commcare.data.xml.TransactionParserFactory; import org.commcare.models.AndroidSessionWrapper; import org.commcare.models.database.SqlStorage; +import org.commcare.services.CommCareKeyManager; import org.commcare.tasks.templates.CommCareTask; import org.commcare.util.CommCarePlatform; import org.commcare.util.LogTypes; @@ -33,6 +34,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Date; @@ -44,6 +46,9 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; +import static org.commcare.models.encryption.EncryptionIO.getDecryptCipher; +import static org.commcare.models.encryption.EncryptionIO.getKeystoreDecryptCipher; + /** * @author ctsims */ @@ -251,8 +256,12 @@ private static Pair reparseRecord(Context context, InputStream is = null; FileInputStream fis = new FileInputStream(path); try { - Cipher decrypter = Cipher.getInstance("AES"); - decrypter.init(Cipher.DECRYPT_MODE, new SecretKeySpec(r.getAesKey(), "AES")); + Cipher decrypter; + if (r.usesKeystoreEncryption()) { + decrypter = getKeystoreDecryptCipher(CommCareKeyManager.retrieveSessionKeyAndTransformation(), fis); + } else { + decrypter = getDecryptCipher(new SecretKeySpec(r.getAesKey(), "AES")); + } is = new CipherInputStream(fis, decrypter); // Construct parser for this form's internal data. @@ -269,6 +278,8 @@ private static Pair reparseRecord(Context context, } catch (InvalidKeyException e) { e.printStackTrace(); throw new RuntimeException("Invalid Key Data while attempting to decode form submission for processing"); + } catch (InvalidAlgorithmParameterException e) { + throw new RuntimeException("Invalid algorithm parameter while attempting to decode form submission for processing"); } finally { fis.close(); if (is != null) { diff --git a/app/src/org/commcare/tasks/FormRecordToFileTask.java b/app/src/org/commcare/tasks/FormRecordToFileTask.java index bee58dd4a1..768f4a7e68 100755 --- a/app/src/org/commcare/tasks/FormRecordToFileTask.java +++ b/app/src/org/commcare/tasks/FormRecordToFileTask.java @@ -12,13 +12,16 @@ import org.commcare.models.database.SqlStorage; import org.commcare.preferences.ServerUrls; import org.commcare.tasks.templates.CommCareTask; +import org.commcare.models.encryption.EncryptionIO; +import org.commcare.services.CommCareKeyManager; import org.commcare.util.LogTypes; +import org.commcare.utils.EncryptionKeyAndTransform; import org.commcare.utils.FileUtil; import org.commcare.utils.FormUploadResult; -import org.commcare.utils.FormUploadUtil; import org.commcare.utils.StorageUtils; import org.commcare.views.notifications.NotificationMessageFactory; import org.commcare.views.notifications.ProcessIssues; +import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.services.Logger; import org.javarosa.core.services.locale.Localization; @@ -26,12 +29,18 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.io.PrintStream; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Properties; import java.util.Vector; import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; import static org.commcare.views.widgets.MediaWidget.AES_EXTENSION; @@ -64,7 +73,12 @@ public FormRecordToFileTask(Context c, String formStoragePath) { * Turn a FormRecord folder from storage into a standard file representation in our file system. * Return an int status code from FormUploadUtil corresponding to the outcome of the transfer */ - private FormUploadResult copyFileInstanceFromStorage(File formRecordFolder, SecretKeySpec decryptionKey) { + private FormUploadResult copyFileInstanceFromStorage( + File formRecordFolder, + Key decryptionKey, + String transformation, + boolean isKeyFromKeystore + ) { File[] files = formRecordFolder.listFiles(File::isFile); Logger.log(TAG, "Trying to get instance with: " + files.length + " files."); @@ -73,10 +87,14 @@ private FormUploadResult copyFileInstanceFromStorage(File formRecordFolder, Secr logTransferBytes(files); - final Cipher decryptCipher = FormUploadUtil.getDecryptCipher(decryptionKey); try { - decryptCopyFiles(files, myDir, decryptCipher); - } catch (IOException e){ + if (isKeyFromKeystore) { + decryptCopyFilesWithKeystore(files, myDir, decryptionKey, transformation); + } else { + final Cipher decryptCipher = EncryptionIO.getDecryptCipher(decryptionKey); + decryptCopyFiles(files, myDir, decryptCipher); + } + } catch (IOException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException e){ Log.d(TAG, "Copying file failed with: " + e.getMessage()); publishProgress(("File writing failed: " + e.getMessage())); return FormUploadResult.FAILURE; @@ -87,18 +105,44 @@ private FormUploadResult copyFileInstanceFromStorage(File formRecordFolder, Secr return FormUploadResult.FULL_SUCCESS; } + private FormUploadResult copyFileInstanceFromStorageWithKeystore(File formRecordFolder, + EncryptionKeyAndTransform keystoreKey) { + return copyFileInstanceFromStorage( + formRecordFolder, + keystoreKey.getKey(), + keystoreKey.getTransformation(), + true + ); + } + private void decryptCopyFiles(File[] files, File targetDirectory, Cipher decryptCipher) throws IOException{ for (File file : files) { // This is not the ideal long term solution for determining whether we need decryption, but works - if (file.getName().endsWith(".xml")) { - FileUtil.copyFile(file, new File(targetDirectory, file.getName()), decryptCipher, null); - } else if (file.getName().endsWith(AES_EXTENSION)) { - FileUtil.copyFile(file, new File(targetDirectory, removeAESExtension(file.getName())), decryptCipher, null); + if (file.getName().endsWith(".xml") || file.getName().endsWith(AES_EXTENSION)) { + String targetName = file.getName().endsWith(AES_EXTENSION) + ? removeAESExtension(file.getName()) : file.getName(); + FileUtil.copyFile(file, new File(targetDirectory, targetName), decryptCipher, null); + } else { + FileUtil.copyFile(file, new File(targetDirectory, file.getName())); + } + } + } + + private void decryptCopyFilesWithKeystore(File[] files, File targetDirectory, Key key, String transformation) throws IOException{ + for (File file : files) { + if (file.getName().endsWith(".xml") || file.getName().endsWith(AES_EXTENSION)) { + String targetName = file.getName().endsWith(AES_EXTENSION) + ? removeAESExtension(file.getName()) : file.getName(); + try (InputStream is = EncryptionIO.getFileInputStreamWithKeystore(file.getAbsolutePath(), key, transformation); + OutputStream os = new FileOutputStream(new File(targetDirectory, targetName))) { + StreamsUtil.writeFromInputToOutputNew(is, os); + } } else { FileUtil.copyFile(file, new File(targetDirectory, file.getName())); } } } + private static boolean isSupportedFiletype(File file){ for (String ext : SUPPORTED_FILE_EXTS) { if (file.getName().endsWith(ext)) { @@ -192,7 +236,12 @@ protected Pair doTaskBackground(String... params //Good! //Time to transfer forms to storage! - results[i] = copyFileInstanceFromStorage(folder, new SecretKeySpec(record.getAesKey(), "AES")); + if (record.usesKeystoreEncryption()) { + results[i] = copyFileInstanceFromStorageWithKeystore(folder, + CommCareKeyManager.retrieveSessionKeyAndTransformation()); + } else { + results[i] = copyFileInstanceFromStorage(folder, new SecretKeySpec(record.getAesKey(), "AES")); + } if (results[i] == FormUploadResult.FAILURE) { publishProgress("Failure during zipping process"); } @@ -210,6 +259,10 @@ protected Pair doTaskBackground(String... params } } + private FormUploadResult copyFileInstanceFromStorage(File folder, SecretKeySpec aes) { + return copyFileInstanceFromStorage(folder, aes, null, false); + } + private static String getExceptionText(Exception e) { try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); diff --git a/app/src/org/commcare/tasks/SaveToDiskTask.java b/app/src/org/commcare/tasks/SaveToDiskTask.java index 30de17c0a5..644465b5f8 100644 --- a/app/src/org/commcare/tasks/SaveToDiskTask.java +++ b/app/src/org/commcare/tasks/SaveToDiskTask.java @@ -13,6 +13,7 @@ import org.commcare.logging.XPathErrorLogger; import org.commcare.models.database.SqlStorage; import org.commcare.models.encryption.EncryptionIO; +import org.commcare.services.CommCareKeyManager; import org.commcare.tasks.templates.CommCareTask; import org.commcare.util.FormMetaIndicatorUtil; import org.commcare.util.LogTypes; @@ -223,8 +224,7 @@ private void exportData(boolean markCompleted) XFormSerializingVisitor serializer = new XFormSerializingVisitor(markCompleted); ByteArrayPayload payload = (ByteArrayPayload)serializer.createSerializedPayload(dataModel); - writeXmlToStream(payload, - EncryptionIO.createFileOutputStream(mFormRecordPath, symetricKey)); + writeXmlToStream(payload, createEncryptedOutputStream(mFormRecordPath)); SqlStorage formRecordStorage = CommCareApplication.instance().getUserStorage(FormRecord.class); updateFormRecord(formRecordStorage, true); @@ -234,8 +234,7 @@ private void exportData(boolean markCompleted) File instanceXml = new File(mFormRecordPath); File submissionXml = new File(instanceXml.getParentFile(), "submission.xml"); // write out submission.xml -- the data to actually submit to aggregate - writeXmlToStream(payload, - EncryptionIO.createFileOutputStream(submissionXml.getAbsolutePath(), symetricKey)); + writeXmlToStream(payload, createEncryptedOutputStream(submissionXml.getAbsolutePath())); // Set this record's status to COMPLETE updateFormRecord(formRecordStorage, false); @@ -261,6 +260,14 @@ private void exportData(boolean markCompleted) } } + private OutputStream createEncryptedOutputStream(String filePath) throws FileNotFoundException { + if (symetricKey == null) { + return EncryptionIO.createFileOutputStreamWithKeystore( + filePath, CommCareKeyManager.retrieveSessionKeyAndTransformation()); + } + return EncryptionIO.createFileOutputStream(filePath, symetricKey); + } + private void writeXmlToStream(ByteArrayPayload payload, OutputStream output) throws IOException { Trace trace = CCPerfMonitoring.INSTANCE.startTracing(CCPerfMonitoring.TRACE_FILE_ENCRYPTION_TIME); @@ -269,7 +276,7 @@ private void writeXmlToStream(ByteArrayPayload payload, OutputStream output) thr StreamsUtil.writeFromInputToOutput(is, output); } finally { output.close(); - CCPerfMonitoring.INSTANCE.stopFileEncryptionTracing(trace, payload.getLength(), XML_EXTENSION, false); + CCPerfMonitoring.INSTANCE.stopFileEncryptionTracing(trace, payload.getLength(), XML_EXTENSION, symetricKey == null); } } diff --git a/app/src/org/commcare/utils/FormUploadUtil.java b/app/src/org/commcare/utils/FormUploadUtil.java index 1923a45bc8..5016a4037a 100644 --- a/app/src/org/commcare/utils/FormUploadUtil.java +++ b/app/src/org/commcare/utils/FormUploadUtil.java @@ -126,10 +126,37 @@ public static FormUploadResult sendInstance(int submissionNumber, File folder, * @throws FileNotFoundException Is raised if xml file isn't found on the * file-system */ - public static FormUploadResult sendInstance(int submissionNumber, File folder, - @Nullable SecretKeySpec key, String url, - @Nullable DataSubmissionListener listener, User user) - throws FileNotFoundException { + public static FormUploadResult sendInstance( + int submissionNumber, + File folder, + @Nullable SecretKeySpec key, + String url, + @Nullable DataSubmissionListener listener, + User user + ) throws FileNotFoundException { + return sendInstanceInternal(submissionNumber, folder, key, null, false, url, listener, user); + } + + public static FormUploadResult sendInstanceWithKeystore( + int submissionNumber, + File folder, + EncryptionKeyAndTransform keystoreKey, + String url, + @Nullable DataSubmissionListener listener, + User user + ) throws FileNotFoundException { + return sendInstanceInternal(submissionNumber, folder, keystoreKey.getKey(), keystoreKey.getTransformation(), true, url, listener, user); + } + + private static FormUploadResult sendInstanceInternal( + int submissionNumber, File folder, + @Nullable Key key, + @Nullable String transformation, + boolean isKeyFromKeystore, + String url, + @Nullable DataSubmissionListener listener, + User user + ) throws FileNotFoundException { File[] files = folder.listFiles(); @@ -159,7 +186,7 @@ public static FormUploadResult sendInstance(int submissionNumber, File folder, List parts = new ArrayList<>(); - if (!buildMultipartEntity(parts, key, files)) { + if (!buildMultipartEntity(parts, key, transformation, isKeyFromKeystore, files)) { return FormUploadResult.RECORD_FAILURE; } @@ -338,9 +365,11 @@ private static long estimateUploadBytes(File[] files) { * file-system */ private static boolean buildMultipartEntity(List parts, - @Nullable SecretKeySpec key, - File[] files) - throws FileNotFoundException { + @Nullable Key key, + @Nullable String transformation, + boolean isKeyFromKeystore, + File[] files + ) throws FileNotFoundException { int numAttachmentsInInstanceFolder = 0; int numAttachmentsSuccessfullyAdded = 0; @@ -351,7 +380,16 @@ private static boolean buildMultipartEntity(List parts, if (!validateSubmissionFile(f)) { return false; } - parts.add(createEncryptedFilePart("xml_submission_file", f, "text/xml", key)); + parts.add( + createEncryptedFilePart( + "xml_submission_file", + f, + "text/xml", + key, + transformation, + isKeyFromKeystore + ) + ); } else { parts.add(createFilePart("xml_submission_file", f, "text/xml")); } @@ -360,14 +398,16 @@ private static boolean buildMultipartEntity(List parts, String contentType = getFileContentType(fileName); if (contentType != null) { numAttachmentsInInstanceFolder++; - numAttachmentsSuccessfullyAdded += addPartToEntity(parts, f, contentType, key); + numAttachmentsSuccessfullyAdded += addPartToEntity(parts, f, contentType, key, transformation, + isKeyFromKeystore); } else if (isSupportedMultimediaFile(fileName)) { numAttachmentsInInstanceFolder++; String mimeType = FileUtil.getMimeType(fileName); if (StringUtils.isEmpty(mimeType)) { mimeType = "application/octet-stream"; } - numAttachmentsSuccessfullyAdded += addPartToEntity(parts, f, mimeType, key); + numAttachmentsSuccessfullyAdded += addPartToEntity(parts, f, mimeType, key, transformation, + isKeyFromKeystore); } else { Logger.log(LogTypes.TYPE_FORM_SUBMISSION, "Could not add unsupported file type to submission entity: " + f.getName()); @@ -385,11 +425,18 @@ private static boolean buildMultipartEntity(List parts, return true; } - private static int addPartToEntity(List parts, File f, String contentType, SecretKeySpec key) { + private static int addPartToEntity( + List parts, + File f, String contentType, + Key key, + String transformation, + boolean isKeyFromKeystore + ) { if (f.length() <= MAX_BYTES) { MultipartBody.Part part; if (f.getName().endsWith(MediaWidget.AES_EXTENSION)) { - part = createEncryptedFilePart(MediaWidget.removeAESExtension(f.getName()), f, contentType, key); + part = createEncryptedFilePart(MediaWidget.removeAESExtension(f.getName()), f, contentType, key, + transformation, isKeyFromKeystore); } else { part = createFilePart(f.getName(), f, contentType); } diff --git a/app/src/org/commcare/views/widgets/ImageWidget.java b/app/src/org/commcare/views/widgets/ImageWidget.java index 0e2470d4c5..80b1ff1fe2 100644 --- a/app/src/org/commcare/views/widgets/ImageWidget.java +++ b/app/src/org/commcare/views/widgets/ImageWidget.java @@ -27,6 +27,7 @@ import org.commcare.dalvik.R; import org.commcare.interfaces.RuntimePermissionRequester; import org.commcare.logic.PendingCalloutInterface; +import org.commcare.services.CommCareKeyManager; import org.commcare.utils.FileUtil; import org.commcare.utils.GlobalConstants; import org.commcare.utils.MediaUtil; @@ -42,6 +43,7 @@ import org.javarosa.form.api.FormEntryPrompt; import java.io.File; +import java.security.Key; import javax.crypto.spec.SecretKeySpec; @@ -193,9 +195,17 @@ public ImageWidget(final Context context, FormEntryPrompt prompt, PendingCallout } else if (encryptedFile.exists()) { checkFileSize(encryptedFile); } - - File toDisplay = getFileToDisplay(mInstanceFolder, mBinaryName, - ((FormEntryActivity)getContext()).getSymetricKey()); + File toDisplay; + if (((FormEntryActivity)getContext()).isKeyFromKeystore()) { + toDisplay = getFileToDisplay(mInstanceFolder, mBinaryName, + CommCareKeyManager.retrieveSessionKeyAndTransformation().getKey(), + CommCareKeyManager.retrieveSessionKeyAndTransformation().getTransformation(), + true + ); + } else { + toDisplay = getFileToDisplay(mInstanceFolder, mBinaryName, + ((FormEntryActivity)getContext()).getSymetricKey(), null, false); + } if (toDisplay.exists()) { Bitmap bmp = MediaUtil.getBitmapScaledToContainer(toDisplay, @@ -221,7 +231,13 @@ public ImageWidget(final Context context, FormEntryPrompt prompt, PendingCallout // If there is an image in the raw folder, use that as the display image, since it is better quality // otherwise checks if the file to be uploaded exists and decrypt if needed - public static File getFileToDisplay(String instanceFolder, String binaryName, SecretKeySpec secretKey) { + public static File getFileToDisplay( + String instanceFolder, + String binaryName, + Key key, + String transformation, + boolean isKeyFromKeystore + ) { File imageBeingSubmitted = new File(instanceFolder + "/" + binaryName); File toDisplay = new File(ImageCaptureProcessing.getRawDirectoryPath(instanceFolder) + "/" + binaryName); if (!toDisplay.exists()) { @@ -231,7 +247,7 @@ public static File getFileToDisplay(String instanceFolder, String binaryName, Se File encryptedFile = new File(imageBeingSubmitted.getAbsolutePath() + MediaWidget.AES_EXTENSION); if (encryptedFile.exists()) { // we need to decrypt the file and store it in a temp path to display - String mTempPath = MediaWidget.decryptMedia(encryptedFile, secretKey); + String mTempPath = MediaWidget.decryptMedia(encryptedFile, key, transformation, isKeyFromKeystore); toDisplay = new File(mTempPath); } } diff --git a/app/src/org/commcare/views/widgets/MediaWidget.java b/app/src/org/commcare/views/widgets/MediaWidget.java index a5e2a1db43..0ba67a467d 100644 --- a/app/src/org/commcare/views/widgets/MediaWidget.java +++ b/app/src/org/commcare/views/widgets/MediaWidget.java @@ -18,7 +18,9 @@ import org.commcare.logic.PendingCalloutInterface; import org.commcare.models.encryption.EncryptionIO; import org.commcare.preferences.HiddenPreferences; +import org.commcare.services.CommCareKeyManager; import org.commcare.util.LogTypes; +import org.commcare.utils.EncryptionKeyAndTransform; import org.commcare.utils.FileExtensionNotFoundException; import org.commcare.utils.FileUtil; import org.commcare.utils.FormUploadUtil; @@ -36,6 +38,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.security.Key; import javax.crypto.spec.SecretKeySpec; @@ -106,7 +109,12 @@ private void reloadFile() { } else if (mTempBinaryPath == null) { File encryptedFile = new File(mInstanceFolder + mBinaryName + AES_EXTENSION); checkFileSize(encryptedFile); - mTempBinaryPath = decryptMedia(encryptedFile, getSecretKey()); + if (((FormEntryActivity)getContext()).isKeyFromKeystore()) { + mTempBinaryPath = decryptMedia(encryptedFile, + CommCareKeyManager.retrieveSessionKeyAndTransformation()); + } else { + mTempBinaryPath = decryptMedia(encryptedFile, getSecretKey(), null, false); + } } else { checkFileSize(new File(mTempBinaryPath)); } @@ -124,8 +132,13 @@ protected String getSourceFilePathToDisplay() { } } + public static String decryptMedia(File f, EncryptionKeyAndTransform encryptionKeyAndTransform) { + return decryptMedia(f, encryptionKeyAndTransform.getKey(), encryptionKeyAndTransform.getTransformation(), + true); + } + // decrypt the given file to a temp path - public static String decryptMedia(File f, SecretKeySpec secretKey) { + public static String decryptMedia(File f, Key key, String transformation, boolean isKeyFromKeystore) { if (!f.getName().endsWith(AES_EXTENSION)) { return null; } @@ -133,7 +146,7 @@ public static String decryptMedia(File f, SecretKeySpec secretKey) { String tempMediaPath = createTempMediaPath(FileUtil.getExtension(removeAESExtension(f.getName()))); try { FileOutputStream fos = new FileOutputStream(tempMediaPath); - InputStream is = EncryptionIO.getFileInputStream(f.getPath(), secretKey); + InputStream is = EncryptionIO.getFileInputStream(f.getPath(), key, transformation, isKeyFromKeystore); StreamsUtil.writeFromInputToOutputNew(is, fos); } catch (IOException e) { throw new RuntimeException("Failed to decrypt media at path " + f.getAbsolutePath() @@ -326,7 +339,11 @@ private void encryptRecordedFileToDestination(String binaryPath) { try { if (HiddenPreferences.isMediaCaptureEncryptionEnabled()) { destMediaPath = destMediaPath + AES_EXTENSION; - EncryptionIO.encryptFile(binaryPath, destMediaPath, getSecretKey()); + if (((FormEntryActivity)getContext()).isKeyFromKeystore()) { + EncryptionIO.encryptFile(binaryPath, destMediaPath, CommCareKeyManager.retrieveSessionKeyAndTransformation()); + } else { + EncryptionIO.encryptFile(binaryPath, destMediaPath, getSecretKey(), null, false); + } Logger.log(LogTypes.TYPE_MEDIA_EVENT, "Media successfully encrypted and saved: " + destMediaPath); } else { FileUtil.copyFile(binaryPath, destMediaPath); diff --git a/app/src/org/commcare/views/widgets/SignatureWidget.java b/app/src/org/commcare/views/widgets/SignatureWidget.java index 9a84d75eb3..97d2183bac 100644 --- a/app/src/org/commcare/views/widgets/SignatureWidget.java +++ b/app/src/org/commcare/views/widgets/SignatureWidget.java @@ -41,6 +41,7 @@ import org.commcare.activities.components.FormEntryInstanceState; import org.commcare.dalvik.R; import org.commcare.logic.PendingCalloutInterface; +import org.commcare.services.CommCareKeyManager; import org.commcare.utils.GlobalConstants; import org.commcare.utils.MediaUtil; import org.commcare.utils.StringUtils; @@ -122,8 +123,17 @@ public SignatureWidget(Context context, FormEntryPrompt prompt, PendingCalloutIn int screenWidth = display.getWidth(); int screenHeight = display.getHeight(); - File toDisplay = getFileToDisplay(mInstanceFolder, mBinaryName, - ((FormEntryActivity)getContext()).getSymetricKey()); + File toDisplay; + if (((FormEntryActivity)getContext()).isKeyFromKeystore()) { + toDisplay = getFileToDisplay(mInstanceFolder, mBinaryName, + CommCareKeyManager.retrieveSessionKeyAndTransformation().getKey(), + CommCareKeyManager.retrieveSessionKeyAndTransformation().getTransformation(), + true + ); + } else { + toDisplay = getFileToDisplay(mInstanceFolder, mBinaryName, + ((FormEntryActivity)getContext()).getSymetricKey(), null, false); + } if (toDisplay.exists()) { Bitmap bmp = MediaUtil.getBitmapScaledToContainer(toDisplay, screenHeight, screenWidth); if (bmp == null) { diff --git a/app/src/org/commcare/xml/FormInstanceXmlParser.java b/app/src/org/commcare/xml/FormInstanceXmlParser.java index fcc63a0a4d..d7f62af868 100644 --- a/app/src/org/commcare/xml/FormInstanceXmlParser.java +++ b/app/src/org/commcare/xml/FormInstanceXmlParser.java @@ -4,6 +4,7 @@ import org.commcare.CommCareApplication; import org.commcare.android.database.user.models.FormRecord; +import org.commcare.services.CommCareKeyManager; import org.commcare.data.xml.TransactionParser; import org.commcare.models.database.SqlStorage; import org.commcare.utils.FileUtil; @@ -81,7 +82,7 @@ public FormRecord parse() throws InvalidStructureException, IOException, XmlPull FormRecord formRecord = new FormRecord(FormRecord.STATUS_UNINDEXED, xmlns, - CommCareApplication.instance().createNewSymmetricKey().getEncoded(), + CommCareKeyManager.generateLegacyKeyOrEmpty(), null, new Date(0), CommCareApplication.instance().getCurrentApp().getAppRecord().getApplicationId()); diff --git a/app/unit-tests/src/org/commcare/android/tests/encryption/FormKeystoreEncryptionTest.java b/app/unit-tests/src/org/commcare/android/tests/encryption/FormKeystoreEncryptionTest.java new file mode 100644 index 0000000000..ea6a8b21b6 --- /dev/null +++ b/app/unit-tests/src/org/commcare/android/tests/encryption/FormKeystoreEncryptionTest.java @@ -0,0 +1,163 @@ +package org.commcare.android.tests.encryption; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.commcare.CommCareApplication; +import org.commcare.CommCareTestApplication; +import org.commcare.android.database.user.models.FormRecord; +import org.commcare.models.encryption.EncryptionIO; +import org.commcare.services.CommCareKeyManager; +import org.commcare.utils.EncryptionKeyAndTransform; +import org.commcare.utils.MockAndroidKeyStoreProvider; +import org.commcare.utils.MockEncryptionKeyProvider; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.spec.SecretKeySpec; + +@Config(application = CommCareTestApplication.class) +@RunWith(AndroidJUnit4.class) +public class FormKeystoreEncryptionTest { + + private static final String TEST_XML = "test form"; + private MockEncryptionKeyProvider keyProvider; + + @Before + public void setUp() { + MockAndroidKeyStoreProvider.registerProvider(); + keyProvider = new MockEncryptionKeyProvider(CommCareApplication.instance()); + } + + @After + public void tearDown() { + MockAndroidKeyStoreProvider.deregisterProvider(); + } + + @Test + public void testFormRecordUsesKeystoreEncryption_withEmptyKey() { + FormRecord record = new FormRecord(FormRecord.STATUS_UNSTARTED, + "http://test.xmlns", new byte[0], null, new Date(), "test-app-id"); + Assert.assertTrue("Empty aesKey should indicate Keystore encryption", + record.usesKeystoreEncryption()); + } + + @Test + public void testFormRecordUsesKeystoreEncryption_withNullKey() { + FormRecord record = new FormRecord(FormRecord.STATUS_UNSTARTED, + "http://test.xmlns", null, null, new Date(), "test-app-id"); + Assert.assertTrue("Null aesKey should indicate Keystore encryption", + record.usesKeystoreEncryption()); + } + + @Test + public void testFormRecordUsesLegacyEncryption_withPopulatedKey() { + byte[] legacyKey = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + FormRecord record = new FormRecord(FormRecord.STATUS_UNSTARTED, + "http://test.xmlns", legacyKey, null, new Date(), "test-app-id"); + Assert.assertFalse("Non-empty aesKey should indicate legacy encryption", + record.usesKeystoreEncryption()); + } + + @Test + public void testKeystoreEncryptionDecryptionRoundTrip() throws IOException { + EncryptionKeyAndTransform kat = keyProvider.getKeyForEncryption(); + File tempFile = File.createTempFile("form_test", ".xml"); + tempFile.deleteOnExit(); + + // Encrypt + try (OutputStream os = EncryptionIO.createFileOutputStreamWithKeystore( + tempFile.getAbsolutePath(), kat)) { + os.write(TEST_XML.getBytes(StandardCharsets.UTF_8)); + } + + // Decrypt + kat = keyProvider.getKeyForDecryption(); + try (InputStream is = EncryptionIO.getFileInputStreamWithKeystore( + tempFile.getAbsolutePath(), kat)) { + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + result.append(line); + } + Assert.assertEquals("Decrypted content should match original", + TEST_XML, result.toString()); + } + } + + @Test + public void testLegacyEncryptionStillWorks() throws IOException { + byte[] keyBytes = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + SecretKeySpec legacyKey = new SecretKeySpec(keyBytes, "AES"); + File tempFile = File.createTempFile("form_legacy_test", ".xml"); + tempFile.deleteOnExit(); + + // Encrypt with legacy path + try (OutputStream os = EncryptionIO.createFileOutputStream( + tempFile.getAbsolutePath(), legacyKey)) { + os.write(TEST_XML.getBytes(StandardCharsets.UTF_8)); + } + + // Decrypt with legacy path + try (InputStream is = EncryptionIO.getFileInputStream( + tempFile.getAbsolutePath(), legacyKey, null, false)) { + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + result.append(line); + } + Assert.assertEquals("Legacy decrypted content should match original", + TEST_XML, result.toString()); + } + } + + @Test + public void testGenerateLegacyKeyOrEmpty_withKeystoreAvailable() { + byte[] key = CommCareKeyManager.generateLegacyKeyOrEmpty(); + Assert.assertEquals("Key should be empty when Keystore is available", + 0, key.length); + } + + @Test + public void testKeystoreEncryptedFileCannotBeReadWithLegacyPath() throws IOException { + EncryptionKeyAndTransform kat = keyProvider.getKeyForEncryption(); + File tempFile = File.createTempFile("form_mismatch_test", ".xml"); + tempFile.deleteOnExit(); + + // Encrypt with Keystore + try (OutputStream os = EncryptionIO.createFileOutputStreamWithKeystore( + tempFile.getAbsolutePath(), kat)) { + os.write(TEST_XML.getBytes(StandardCharsets.UTF_8)); + } + + // Attempting to decrypt with a legacy key should fail + byte[] keyBytes = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}; + SecretKeySpec wrongKey = new SecretKeySpec(keyBytes, "AES"); + try (InputStream is = EncryptionIO.getFileInputStream( + tempFile.getAbsolutePath(), wrongKey, null, false)) { + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + result.append(line); + } + Assert.assertNotEquals("Decrypting Keystore file with legacy key should not produce original content", + TEST_XML, result.toString()); + } + } +} \ No newline at end of file