From 4d0494a355f12cc72526efa69d6d5f2b9df3bcfa Mon Sep 17 00:00:00 2001 From: rommansabbir Date: Tue, 6 Aug 2024 22:31:13 +0600 Subject: [PATCH] feat: Implementing encrypted local storage for user sessions with tests --- parse/build.gradle | 2 + .../com/parse/EncryptedFileObjectStore.java | 110 +++++++++++ .../main/java/com/parse/ParseCorePlugins.java | 5 +- .../main/java/com/parse/ParseFileUtils.java | 107 +++++++++++ .../com/parse/ParseObjectStoreMigrator.java | 69 +++++++ .../java/com/parse/AlgorithmParameterSpec.kt | 6 + .../java/com/parse/AndroidKeyStoreProvider.kt | 177 ++++++++++++++++++ .../java/com/parse/AndroidOpenSSLProvider.kt | 64 +++++++ .../parse/EncryptedFileObjectStoreTest.java | 119 ++++++++++++ .../com/parse/ParseObjectStoreMigratorTest.kt | 101 ++++++++++ .../java/com/parse/RobolectricKeyStore.kt | 17 ++ 11 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 parse/src/main/java/com/parse/EncryptedFileObjectStore.java create mode 100644 parse/src/main/java/com/parse/ParseObjectStoreMigrator.java create mode 100644 parse/src/test/java/com/parse/AlgorithmParameterSpec.kt create mode 100644 parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt create mode 100644 parse/src/test/java/com/parse/AndroidOpenSSLProvider.kt create mode 100644 parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java create mode 100644 parse/src/test/java/com/parse/ParseObjectStoreMigratorTest.kt create mode 100644 parse/src/test/java/com/parse/RobolectricKeyStore.kt diff --git a/parse/build.gradle b/parse/build.gradle index 32b8129c3..1630380ed 100644 --- a/parse/build.gradle +++ b/parse/build.gradle @@ -1,4 +1,5 @@ apply plugin: "com.android.library" +apply plugin: "kotlin-android" apply plugin: "maven-publish" apply plugin: "io.freefair.android-javadoc-jar" apply plugin: "io.freefair.android-sources-jar" @@ -50,6 +51,7 @@ dependencies { api "androidx.core:core:1.8.0" api "com.squareup.okhttp3:okhttp:$okhttpVersion" api project(':bolts-tasks') + implementation "androidx.security:security-crypto:1.1.0-alpha03" testImplementation "org.junit.jupiter:junit-jupiter:$rootProject.ext.jupiterVersion" testImplementation "org.skyscreamer:jsonassert:1.5.0" diff --git a/parse/src/main/java/com/parse/EncryptedFileObjectStore.java b/parse/src/main/java/com/parse/EncryptedFileObjectStore.java new file mode 100644 index 000000000..b31ca647b --- /dev/null +++ b/parse/src/main/java/com/parse/EncryptedFileObjectStore.java @@ -0,0 +1,110 @@ +package com.parse; + +import android.content.Context; + +import androidx.security.crypto.EncryptedFile; +import androidx.security.crypto.MasterKey; +import com.parse.boltsinternal.Task; +import org.json.JSONException; +import org.json.JSONObject; +import java.io.File; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.concurrent.Callable; + +/** + * a file based {@link ParseObjectStore} using Jetpack's {@link EncryptedFile} class to protect files from a malicious copy. + */ +class EncryptedFileObjectStore implements ParseObjectStore { + + private final String className; + private final File file; + private final EncryptedFile encryptedFile; + private final ParseObjectCurrentCoder coder; + + public EncryptedFileObjectStore(Class clazz, File file, ParseObjectCurrentCoder coder) { + this(getSubclassingController().getClassName(clazz), file, coder); + } + + public EncryptedFileObjectStore(String className, File file, ParseObjectCurrentCoder coder) { + this.className = className; + this.file = file; + this.coder = coder; + Context context = ParsePlugins.get().applicationContext(); + try { + encryptedFile = new EncryptedFile.Builder(context, file, new MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).build(); + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException(e.getMessage()); + } + } + + private static ParseObjectSubclassingController getSubclassingController() { + return ParseCorePlugins.getInstance().getSubclassingController(); + } + + /** + * Saves the {@code ParseObject} to the a file on disk as JSON in /2/ format. + * + * @param current ParseObject which needs to be saved to disk. + * @throws IOException thrown if an error occurred during writing of the file + * @throws GeneralSecurityException thrown if there is an error with encryption keys or during the encryption of the file + */ + private void saveToDisk(ParseObject current) throws IOException, GeneralSecurityException { + JSONObject json = coder.encode(current.getState(), null, PointerEncoder.get()); + ParseFileUtils.writeJSONObjectToFile(encryptedFile, json); + } + + /** + * Retrieves a {@code ParseObject} from a file on disk in /2/ format. + * + * @return The {@code ParseObject} that was retrieved. If the file wasn't found, or the contents + * of the file is an invalid {@code ParseObject}, returns {@code null}. + * @throws GeneralSecurityException thrown if there is an error with encryption keys or during the encryption of the file + * @throws JSONException thrown if an error occurred during the decoding process of the ParseObject to a JSONObject + * @throws IOException thrown if an error occurred during writing of the file + */ + private T getFromDisk() throws GeneralSecurityException, JSONException, IOException { + return ParseObject.from(coder.decode(ParseObject.State.newBuilder(className), ParseFileUtils.readFileToJSONObject(encryptedFile), ParseDecoder.get()).isComplete(true).build()); + } + + @Override + public Task getAsync() { + return Task.call(new Callable() { + @Override + public T call() throws Exception { + if (!file.exists()) return null; + try { + return getFromDisk(); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e.getMessage()); + } + } + }, ParseExecutors.io()); + } + + @Override + public Task setAsync(T object) { + return Task.call(() -> { + if (file.exists() && !ParseFileUtils.deleteQuietly(file)) throw new RuntimeException("Unable to delete"); + try { + saveToDisk(object); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e.getMessage()); + } + return null; + }, ParseExecutors.io()); + } + + @Override + public Task existsAsync() { + return Task.call(file::exists, ParseExecutors.io()); + } + + @Override + public Task deleteAsync() { + return Task.call(() -> { + if (file.exists() && !ParseFileUtils.deleteQuietly(file)) throw new RuntimeException("Unable to delete"); + return null; + }, ParseExecutors.io()); + } +} diff --git a/parse/src/main/java/com/parse/ParseCorePlugins.java b/parse/src/main/java/com/parse/ParseCorePlugins.java index 01d5ed54b..ed18f230d 100644 --- a/parse/src/main/java/com/parse/ParseCorePlugins.java +++ b/parse/src/main/java/com/parse/ParseCorePlugins.java @@ -135,7 +135,10 @@ public ParseCurrentUserController getCurrentUserController() { Parse.isLocalDatastoreEnabled() ? new OfflineObjectStore<>(ParseUser.class, PIN_CURRENT_USER, fileStore) : fileStore; - ParseCurrentUserController controller = new CachedCurrentUserController(store); + EncryptedFileObjectStore encryptedFileObjectStore = new EncryptedFileObjectStore<>(ParseUser.class, file, ParseUserCurrentCoder.get()); + ParseObjectStoreMigrator storeMigrator = new ParseObjectStoreMigrator<>(encryptedFileObjectStore, store); + ParseCurrentUserController controller = new CachedCurrentUserController(storeMigrator); + currentUserController.compareAndSet(null, controller); currentUserController.compareAndSet(null, controller); } return currentUserController.get(); diff --git a/parse/src/main/java/com/parse/ParseFileUtils.java b/parse/src/main/java/com/parse/ParseFileUtils.java index c48f7b517..804f8c140 100644 --- a/parse/src/main/java/com/parse/ParseFileUtils.java +++ b/parse/src/main/java/com/parse/ParseFileUtils.java @@ -18,6 +18,8 @@ import android.net.Uri; import androidx.annotation.NonNull; +import androidx.security.crypto.EncryptedFile; + import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -27,6 +29,7 @@ import java.io.OutputStream; import java.nio.channels.FileChannel; import java.nio.charset.Charset; +import java.security.GeneralSecurityException; import java.util.List; import org.json.JSONException; import org.json.JSONObject; @@ -63,6 +66,27 @@ public static byte[] readFileToByteArray(File file) throws IOException { // ----------------------------------------------------------------------- + /** + * + * Reads the contents of an encrypted file into a byte array. The file is always closed. + * + * @param file the encrypted file to read, must not be null + * @return the file contents, never null + * @throws IOException in case of an I/O error + * @throws GeneralSecurityException in case of an encryption related error + */ + public static byte[] readFileToByteArray(EncryptedFile file) throws IOException, GeneralSecurityException { + InputStream in = null; + try { + in = file.openFileInput(); + return ParseIOUtils.toByteArray(in); + } finally { + ParseIOUtils.closeQuietly(in); + } + } + + + /** * Opens a {@link FileInputStream} for the specified file, providing better error messages than * simply calling new FileInputStream(file). @@ -116,6 +140,26 @@ public static void writeByteArrayToFile(File file, byte[] data) throws IOExcepti } } + /** + * Writes a byte array to an encrypted file, will not create the file if it does not exist. + * + * @param file the file to write to + * @param data the content to write to the file + * @throws IOException in case of an I/O error + * @throws GeneralSecurityException in case of an encryption related error + */ + public static void writeByteArrayToFile(EncryptedFile file, byte[] data) throws IOException, GeneralSecurityException { + OutputStream out = null; + try { + out = file.openFileOutput(); + out.write(data); + } finally { + ParseIOUtils.closeQuietly(out); + } + } + + + /** * Writes a content uri to a file creating the file if it does not exist. * @@ -549,6 +593,30 @@ public static boolean isSymlink(final File file) throws IOException { return !fileInCanonicalDir.getCanonicalFile().equals(fileInCanonicalDir.getAbsoluteFile()); } + /** + * @param file the encrypted file to read + * @param encoding the file encoding used when written to disk + * @return Reads the contents of an encrypted file into a {@link String}. The file is always closed. + * @throws IOException thrown if an error occurred during writing of the file + * @throws GeneralSecurityException thrown if there is an error with encryption keys or during the encryption of the file + */ + public static String readFileToString(EncryptedFile file, Charset encoding) throws IOException, GeneralSecurityException { + return new String(readFileToByteArray(file), encoding); + } + + /** + * @param file the encrypted file to read + * @param encoding the file encoding used when written to disk + * @return Reads the contents of an encrypted file into a {@link String}. The file is always closed. + * @throws IOException thrown if an error occurred during writing of the file + * @throws GeneralSecurityException thrown if there is an error with encryption keys or during the encryption of the file + */ + public static String readFileToString(EncryptedFile file, String encoding) throws IOException, GeneralSecurityException { + return readFileToString(file, Charset.forName(encoding)); + } + + + // region String public static String readFileToString(File file, Charset encoding) throws IOException { @@ -569,6 +637,32 @@ public static void writeStringToFile(File file, String string, String encoding) writeStringToFile(file, string, Charset.forName(encoding)); } + /** + * Writes a {@link JSONObject} to an encrypted file, will throw an error if the file already exists. + * @param file the encrypted file to use for writing. + * @param string the text to write. + * @param encoding the encoding used for the text written. + * @throws IOException thrown if an error occurred during writing of the file + * @throws GeneralSecurityException thrown if there is an error with encryption keys or during the encryption of the file + */ + public static void writeStringToFile(EncryptedFile file, String string, Charset encoding) + throws IOException, GeneralSecurityException { + writeByteArrayToFile(file, string.getBytes(encoding)); + } + + /** + * Writes a {@link JSONObject} to an encrypted file, will throw an error if the file already exists. + * @param file the encrypted file to use for writing. + * @param string the text to write. + * @param encoding the encoding used for the text written. + * @throws IOException thrown if an error occurred during writing of the file + * @throws GeneralSecurityException thrown if there is an error with encryption keys or during the encryption of the file + */ + public static void writeStringToFile(EncryptedFile file, String string, String encoding) + throws IOException, GeneralSecurityException { + writeStringToFile(file, string, Charset.forName(encoding)); + } + // endregion // region JSONObject @@ -584,5 +678,18 @@ public static void writeJSONObjectToFile(File file, JSONObject json) throws IOEx ParseFileUtils.writeByteArrayToFile(file, json.toString().getBytes("UTF-8")); } + /** Reads the contents of an encrypted file into a {@link JSONObject}. The file is always closed. */ + public static JSONObject readFileToJSONObject(EncryptedFile file) throws IOException, JSONException, GeneralSecurityException { + String content = readFileToString(file, "UTF-8"); + return new JSONObject(content); + } + + /** Writes a {@link JSONObject} to an encrypted file, will throw an error if the file already exists. */ + public static void writeJSONObjectToFile(EncryptedFile file, JSONObject json) throws IOException, GeneralSecurityException { + ParseFileUtils.writeByteArrayToFile(file, json.toString().getBytes("UTF-8")); + } + + + // endregion } diff --git a/parse/src/main/java/com/parse/ParseObjectStoreMigrator.java b/parse/src/main/java/com/parse/ParseObjectStoreMigrator.java new file mode 100644 index 000000000..47da1ecde --- /dev/null +++ b/parse/src/main/java/com/parse/ParseObjectStoreMigrator.java @@ -0,0 +1,69 @@ +package com.parse; + +import com.parse.boltsinternal.Continuation; +import com.parse.boltsinternal.Task; + +import java.util.Arrays; + +/** + * Use this utility class to migrate from one {@link ParseObjectStore} to another + */ +class ParseObjectStoreMigrator implements ParseObjectStore { + + private final ParseObjectStore store; + private final ParseObjectStore legacy; + + /** + * @param store the new {@link ParseObjectStore} to migrate to + * @param legacy the old {@link ParseObjectStore} to migrate from + */ + public ParseObjectStoreMigrator(ParseObjectStore store, ParseObjectStore legacy) { + this.store = store; + this.legacy = legacy; + } + + @Override + public Task getAsync() { + return store.getAsync().continueWithTask(new Continuation>() { + @Override + public Task then(Task task) throws Exception { + if (task.getResult() != null) return task; + return legacy.getAsync().continueWithTask(new Continuation>() { + @Override + public Task then(Task task) throws Exception { + T object = task.getResult(); + if (object == null) return task; + return legacy.deleteAsync().continueWith(task1 -> ParseTaskUtils.wait(store.setAsync(object))).onSuccess(task1 -> object); + } + }); + } + }); + } + + @Override + public Task setAsync(T object) { + return store.setAsync(object); + } + + @Override + public Task existsAsync() { + return store.existsAsync().continueWithTask(new Continuation>() { + @Override + public Task then(Task task) throws Exception { + if (task.getResult()) return Task.forResult(true); + return legacy.existsAsync(); + } + }); + } + + @Override + public Task deleteAsync() { + Task storeTask = store.deleteAsync(); + return Task.whenAll(Arrays.asList(legacy.deleteAsync(), storeTask)).continueWithTask(new Continuation>() { + @Override + public Task then(Task task1) throws Exception { + return storeTask; + } + }); + } +} diff --git a/parse/src/test/java/com/parse/AlgorithmParameterSpec.kt b/parse/src/test/java/com/parse/AlgorithmParameterSpec.kt new file mode 100644 index 000000000..7e97ee6fa --- /dev/null +++ b/parse/src/test/java/com/parse/AlgorithmParameterSpec.kt @@ -0,0 +1,6 @@ +package com.parse + +import java.security.spec.AlgorithmParameterSpec + +internal val AlgorithmParameterSpec.keystoreAlias: String + get() = this::class.java.getDeclaredMethod("getKeystoreAlias").invoke(this) as String diff --git a/parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt b/parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt new file mode 100644 index 000000000..8087b2117 --- /dev/null +++ b/parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt @@ -0,0 +1,177 @@ +package com.parse + +/* + * Copyright 2020 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.InputStream +import java.io.OutputStream +import java.security.Key +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyPairGeneratorSpi +import java.security.KeyStore +import java.security.KeyStoreSpi +import java.security.Provider +import java.security.SecureRandom +import java.security.cert.Certificate +import java.security.spec.AlgorithmParameterSpec +import java.util.Collections +import java.util.Date +import java.util.Enumeration +import javax.crypto.KeyGenerator +import javax.crypto.KeyGeneratorSpi +import javax.crypto.SecretKey + +class AndroidKeyStoreProvider : Provider("AndroidKeyStore", 1.0, "") { + init { + put("KeyStore.AndroidKeyStore", AndroidKeyStore::class.java.name) + put("KeyGenerator.AES", AesKeyGenerator::class.java.name) + put("KeyGenerator.HmacSHA256", HmacSHA256KeyGenerator::class.java.name) + put("KeyPairGenerator.RSA", RsaKeyPairGenerator::class.java.name) + } + + @Suppress("TooManyFunctions") + class AndroidKeyStore : KeyStoreSpi() { + override fun engineIsKeyEntry(alias: String?): Boolean = wrapped.isKeyEntry(alias) + + override fun engineIsCertificateEntry(alias: String?): Boolean = + wrapped.isCertificateEntry(alias) + + override fun engineGetCertificate(alias: String?): Certificate = + wrapped.getCertificate(alias) + + override fun engineGetCreationDate(alias: String?): Date = wrapped.getCreationDate(alias) + + override fun engineDeleteEntry(alias: String?) { + storedKeys.remove(alias) + } + + override fun engineSetKeyEntry( + alias: String?, + key: Key?, + password: CharArray?, + chain: Array?, + ) = + wrapped.setKeyEntry(alias, key, password, chain) + + override fun engineSetKeyEntry( + alias: String?, + key: ByteArray?, + chain: Array?, + ) = wrapped.setKeyEntry(alias, key, chain) + + override fun engineStore(stream: OutputStream?, password: CharArray?) = + wrapped.store(stream, password) + + override fun engineSize(): Int = wrapped.size() + + override fun engineAliases(): Enumeration = Collections.enumeration(storedKeys.keys) + + override fun engineContainsAlias(alias: String?): Boolean = storedKeys.containsKey(alias) + + override fun engineLoad(stream: InputStream?, password: CharArray?) = + wrapped.load(stream, password) + + override fun engineGetCertificateChain(alias: String?): Array? = + wrapped.getCertificateChain(alias) + + override fun engineSetCertificateEntry(alias: String?, cert: Certificate?) = + wrapped.setCertificateEntry(alias, cert) + + override fun engineGetCertificateAlias(cert: Certificate?): String? = + wrapped.getCertificateAlias(cert) + + override fun engineGetKey(alias: String?, password: CharArray?): Key? = + (storedKeys[alias] as? KeyStore.SecretKeyEntry)?.secretKey + + override fun engineGetEntry( + p0: String, + p1: KeyStore.ProtectionParameter?, + ): KeyStore.Entry? = storedKeys[p0] + + override fun engineSetEntry( + p0: String, + p1: KeyStore.Entry, + p2: KeyStore.ProtectionParameter?, + ) { + storedKeys[p0] = p1 + } + + override fun engineLoad(p0: KeyStore.LoadStoreParameter?) = wrapped.load(p0) + + override fun engineStore(p0: KeyStore.LoadStoreParameter?) = wrapped.store(p0) + + override fun engineEntryInstanceOf(p0: String?, p1: Class?) = + wrapped.entryInstanceOf(p0, p1) + + companion object { + private val wrapped = KeyStore.getInstance("BKS", "BC") + internal val storedKeys = mutableMapOf() + } + } + + class AesKeyGenerator : KeyGeneratorSpi() { + private val wrapped = KeyGenerator.getInstance("AES", "BC") + private var lastSpec: AlgorithmParameterSpec? = null + + override fun engineInit(random: SecureRandom?) = wrapped.init(random) + + override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) = + wrapped.init(random).also { + lastSpec = params + } + + override fun engineInit(keysize: Int, random: SecureRandom?) = wrapped.init(keysize, random) + + override fun engineGenerateKey(): SecretKey = wrapped.generateKey().also { + AndroidKeyStore.storedKeys[lastSpec!!.keystoreAlias] = KeyStore.SecretKeyEntry(it) + } + } + + class HmacSHA256KeyGenerator : KeyGeneratorSpi() { + private val wrapped = KeyGenerator.getInstance("HmacSHA256", "BC") + private var lastSpec: AlgorithmParameterSpec? = null + + override fun engineInit(random: SecureRandom?) = wrapped.init(random) + override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) = + wrapped.init(random).also { + lastSpec = params + } + + override fun engineInit(keysize: Int, random: SecureRandom?) = Unit + override fun engineGenerateKey(): SecretKey = wrapped.generateKey().also { + AndroidKeyStore.storedKeys[lastSpec!!.keystoreAlias] = KeyStore.SecretKeyEntry(it) + } + } + + class RsaKeyPairGenerator : KeyPairGeneratorSpi() { + private val wrapped = KeyPairGenerator.getInstance("RSA", "BC") + + private var lastSpec: AlgorithmParameterSpec? = null + + // {@link KeyPair#toCertificate()} is used for generating JcaX509 certificates using org.bouncycastle library which might not be required now, but can be implemented when needed. + override fun generateKeyPair(): KeyPair = wrapped.generateKeyPair().also { keyPair -> + null +// AndroidKeyStore.storedKeys[lastSpec!!.keystoreAlias] = KeyStore.PrivateKeyEntry(keyPair.private, arrayOf(keyPair.toCertificate())) + } + + override fun initialize(p0: Int, p1: SecureRandom?) = Unit + + override fun initialize(p0: AlgorithmParameterSpec?, p1: SecureRandom?) { + lastSpec = p0 + } + } +} diff --git a/parse/src/test/java/com/parse/AndroidOpenSSLProvider.kt b/parse/src/test/java/com/parse/AndroidOpenSSLProvider.kt new file mode 100644 index 000000000..2f98e8742 --- /dev/null +++ b/parse/src/test/java/com/parse/AndroidOpenSSLProvider.kt @@ -0,0 +1,64 @@ +package com.parse + +/* + * Copyright 2020 Appmattus Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.annotation.SuppressLint +import java.security.AlgorithmParameters +import java.security.Key +import java.security.Provider +import java.security.SecureRandom +import java.security.spec.AlgorithmParameterSpec +import javax.crypto.Cipher +import javax.crypto.CipherSpi + +class AndroidOpenSSLProvider : Provider("AndroidOpenSSL", 1.0, "") { + init { + put("Cipher.RSA/ECB/PKCS1Padding", RsaCipher::class.java.name) + } + + @Suppress("TooManyFunctions") + class RsaCipher : CipherSpi() { + @SuppressLint("GetInstance") + private val wrapped = Cipher.getInstance("RSA/ECB/PKCS1Padding", "BC") + + override fun engineSetMode(p0: String?) = Unit + + override fun engineInit(p0: Int, p1: Key?, p2: SecureRandom?) = wrapped.init(p0, p1, p2) + + override fun engineInit(p0: Int, p1: Key?, p2: AlgorithmParameterSpec?, p3: SecureRandom?) = wrapped.init(p0, p1, p2, p3) + + override fun engineInit(p0: Int, p1: Key?, p2: AlgorithmParameters?, p3: SecureRandom?) = wrapped.init(p0, p1, p2, p3) + + override fun engineGetIV(): ByteArray = wrapped.iv + + override fun engineDoFinal(p0: ByteArray?, p1: Int, p2: Int): ByteArray = wrapped.doFinal(p0, p1, p2) + + override fun engineDoFinal(p0: ByteArray?, p1: Int, p2: Int, p3: ByteArray?, p4: Int) = wrapped.doFinal(p0, p1, p2, p3, p4) + + override fun engineSetPadding(p0: String?) = Unit + + override fun engineGetParameters(): AlgorithmParameters = wrapped.parameters + + override fun engineUpdate(p0: ByteArray?, p1: Int, p2: Int): ByteArray = wrapped.update(p0, p1, p2) + + override fun engineUpdate(p0: ByteArray?, p1: Int, p2: Int, p3: ByteArray?, p4: Int): Int = wrapped.update(p0, p1, p2, p3, p4) + + override fun engineGetBlockSize(): Int = wrapped.blockSize + + override fun engineGetOutputSize(p0: Int): Int = wrapped.getOutputSize(p0) + } +} diff --git a/parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java b/parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java new file mode 100644 index 000000000..78458853c --- /dev/null +++ b/parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2015-present, Parse, LLC. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +package com.parse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; +import android.content.Context; +import androidx.security.crypto.EncryptedFile; +import androidx.security.crypto.MasterKey; +import androidx.test.platform.app.InstrumentationRegistry; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.skyscreamer.jsonassert.JSONCompareMode; +import java.io.File; +import kotlin.jvm.JvmStatic; + +@RunWith(RobolectricTestRunner.class) +public class EncryptedFileObjectStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Before + public void setUp() { + RobolectricKeyStore.INSTANCE.getSetup(); + ParseObject.registerSubclass(ParseUser.class); + Parse.initialize(new Parse.Configuration.Builder(InstrumentationRegistry.getInstrumentation().getTargetContext()).server("http://parse.com").build()); + } + + @After + public void tearDown() { + ParseObject.unregisterSubclass(ParseUser.class); + } + + @Test + public void testSetAsync() throws Exception { + File file = new File(temporaryFolder.getRoot(), "test"); + + ParseUser.State state = mock(ParseUser.State.class); + JSONObject json = new JSONObject(); + json.put("foo", "bar"); + ParseUserCurrentCoder coder = mock(ParseUserCurrentCoder.class); + when(coder.encode(eq(state), isNull(), any(PointerEncoder.class))) + .thenReturn(json); + EncryptedFileObjectStore store = new EncryptedFileObjectStore<>(ParseUser.class, file, coder); + + ParseUser user = mock(ParseUser.class); + when(user.getState()).thenReturn(state); + ParseTaskUtils.wait(store.setAsync(user)); + + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + EncryptedFile encryptedFile = new EncryptedFile.Builder(context, file, new MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).build(); + JSONObject jsonAgain = ParseFileUtils.readFileToJSONObject(encryptedFile); + assertEquals(json, jsonAgain, JSONCompareMode.STRICT); + } + + @Test + public void testGetAsync() throws Exception { + File file = new File(temporaryFolder.getRoot(), "test"); + + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + EncryptedFile encryptedFile = new EncryptedFile.Builder(context, file, new MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).build(); + + JSONObject json = new JSONObject(); + ParseFileUtils.writeJSONObjectToFile(encryptedFile, json); + + ParseUser.State.Builder builder = new ParseUser.State.Builder(); + builder.put("foo", "bar"); + ParseUserCurrentCoder coder = mock(ParseUserCurrentCoder.class); + when(coder.decode( + any(ParseUser.State.Builder.class), + any(JSONObject.class), + any(ParseDecoder.class))) + .thenReturn(builder); + EncryptedFileObjectStore store = new EncryptedFileObjectStore<>(ParseUser.class, file, coder); + + ParseUser user = ParseTaskUtils.wait(store.getAsync()); + assertEquals("bar", user.getState().get("foo")); + } + + @Test + public void testExistsAsync() throws Exception { + File file = temporaryFolder.newFile("test"); + EncryptedFileObjectStore store = new EncryptedFileObjectStore<>(ParseUser.class, file, null); + assertTrue(ParseTaskUtils.wait(store.existsAsync())); + + temporaryFolder.delete(); + assertFalse(ParseTaskUtils.wait(store.existsAsync())); + } + + @Test + public void testDeleteAsync() throws Exception { + File file = temporaryFolder.newFile("test"); + EncryptedFileObjectStore store = new EncryptedFileObjectStore<>(ParseUser.class, file, null); + assertTrue(file.exists()); + + ParseTaskUtils.wait(store.deleteAsync()); + assertFalse(file.exists()); + } +} diff --git a/parse/src/test/java/com/parse/ParseObjectStoreMigratorTest.kt b/parse/src/test/java/com/parse/ParseObjectStoreMigratorTest.kt new file mode 100644 index 000000000..5335dcc10 --- /dev/null +++ b/parse/src/test/java/com/parse/ParseObjectStoreMigratorTest.kt @@ -0,0 +1,101 @@ +package com.parse + +import com.parse.boltsinternal.Task +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) +class ParseObjectStoreMigratorTest { + + private lateinit var store: ParseObjectStore + private lateinit var legacy: ParseObjectStore + private lateinit var migrator: ParseObjectStoreMigrator + + @BeforeEach + fun setUp() { + store = mock(ParseObjectStore::class.java) as ParseObjectStore + legacy = mock(ParseObjectStore::class.java) as ParseObjectStore + migrator = ParseObjectStoreMigrator(store, legacy) + } + + @Test + fun testGetAsyncWhenStoreHasData() { + val parseObject = mock(ParseObject::class.java) + `when`(store.getAsync()).thenReturn(Task.forResult(parseObject)) + + val result = migrator.getAsync().result + + assertEquals(parseObject, result) + verify(store, times(1)).getAsync() + verify(legacy, never()).getAsync() + } + + @Test + fun testGetAsyncWhenStoreIsEmptyAndLegacyHasData() { + val parseObject = mock(ParseObject::class.java) + `when`(store.getAsync()).thenReturn(Task.forResult(null)) + `when`(legacy.getAsync()).thenReturn(Task.forResult(parseObject)) + `when`(legacy.deleteAsync()).thenReturn(Task.forResult(null)) + `when`(store.setAsync(parseObject)).thenReturn(Task.forResult(null)) + + val result = migrator.getAsync().result + + assertEquals(parseObject, result) + verify(store, times(1)).getAsync() + verify(legacy, times(1)).getAsync() + verify(legacy, times(1)).deleteAsync() + verify(store, times(1)).setAsync(parseObject) + } + + @Test + fun testSetAsync() { + val parseObject = mock(ParseObject::class.java) + `when`(store.setAsync(parseObject)).thenReturn(Task.forResult(null)) + + migrator.setAsync(parseObject).waitForCompletion() + + verify(store, times(1)).setAsync(parseObject) + } + + @Test + fun testExistsAsyncWhenStoreHasData() { + `when`(store.existsAsync()).thenReturn(Task.forResult(true)) + + val result = migrator.existsAsync().result + + assertTrue(result) + verify(store, times(1)).existsAsync() + verify(legacy, never()).existsAsync() + } + + @Test + fun testExistsAsyncWhenStoreIsEmptyAndLegacyHasData() { + `when`(store.existsAsync()).thenReturn(Task.forResult(false)) + `when`(legacy.existsAsync()).thenReturn(Task.forResult(true)) + + val result = migrator.existsAsync().result + + assertTrue(result) + verify(store, times(1)).existsAsync() + verify(legacy, times(1)).existsAsync() + } + + @Test + fun testDeleteAsync() { + `when`(store.deleteAsync()).thenReturn(Task.forResult(null)) + `when`(legacy.deleteAsync()).thenReturn(Task.forResult(null)) + + migrator.deleteAsync().waitForCompletion() + + verify(store, times(1)).deleteAsync() + verify(legacy, times(1)).deleteAsync() + } +} diff --git a/parse/src/test/java/com/parse/RobolectricKeyStore.kt b/parse/src/test/java/com/parse/RobolectricKeyStore.kt new file mode 100644 index 000000000..7162ff1c5 --- /dev/null +++ b/parse/src/test/java/com/parse/RobolectricKeyStore.kt @@ -0,0 +1,17 @@ +package com.parse + +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security + +object RobolectricKeyStore { + + val setup by lazy { + Security.removeProvider("AndroidKeyStore") + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.removeProvider("AndroidOpenSSL") + + Security.addProvider(AndroidKeyStoreProvider()) + Security.addProvider(BouncyCastleProvider()) + Security.addProvider(AndroidOpenSSLProvider()) + } +}