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..835d1c884 --- /dev/null +++ b/parse/src/main/java/com/parse/EncryptedFileObjectStore.java @@ -0,0 +1,113 @@ +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..1a8009b27 100644 --- a/parse/src/main/java/com/parse/ParseCorePlugins.java +++ b/parse/src/main/java/com/parse/ParseCorePlugins.java @@ -135,7 +135,9 @@ 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); } return currentUserController.get(); diff --git a/parse/src/main/java/com/parse/ParseFileUtils.java b/parse/src/main/java/com/parse/ParseFileUtils.java index 50af66982..9ef9817a8 100644 --- a/parse/src/main/java/com/parse/ParseFileUtils.java +++ b/parse/src/main/java/com/parse/ParseFileUtils.java @@ -17,6 +17,8 @@ package com.parse; import androidx.annotation.NonNull; +import androidx.security.crypto.EncryptedFile; + import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -26,6 +28,8 @@ import java.io.OutputStream; import java.nio.channels.FileChannel; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.util.List; import org.json.JSONException; import org.json.JSONObject; @@ -60,6 +64,25 @@ 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); + } + } + // ----------------------------------------------------------------------- /** @@ -115,6 +138,24 @@ 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); + } + } + // ----------------------------------------------------------------------- /** @@ -534,13 +575,62 @@ public static String readFileToString(File file, String encoding) throws IOExcep return readFileToString(file, Charset.forName(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, 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)); + } + public static void writeStringToFile(File file, String string, Charset encoding) - throws IOException { + throws IOException { writeByteArrayToFile(file, string.getBytes(encoding)); } public static void writeStringToFile(File file, String string, String encoding) - throws IOException { + throws IOException { + 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)); } @@ -559,5 +649,16 @@ 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/AlgorithmParameterSpecExt.kt b/parse/src/test/java/com/parse/AlgorithmParameterSpecExt.kt new file mode 100644 index 000000000..c33dd1722 --- /dev/null +++ b/parse/src/test/java/com/parse/AlgorithmParameterSpecExt.kt @@ -0,0 +1,22 @@ +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.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..27816e20d --- /dev/null +++ b/parse/src/test/java/com/parse/AndroidKeyStoreProvider.kt @@ -0,0 +1,141 @@ +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.* +import java.security.cert.Certificate +import java.security.spec.AlgorithmParameterSpec +import java.util.* +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..98776d334 --- /dev/null +++ b/parse/src/test/java/com/parse/EncryptedFileObjectStoreTest.java @@ -0,0 +1,125 @@ +/* + * 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(); + + @JvmStatic + @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), (ParseOperationSet) 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/RobolectricKeyStore.kt b/parse/src/test/java/com/parse/RobolectricKeyStore.kt new file mode 100644 index 000000000..10f68b5ff --- /dev/null +++ b/parse/src/test/java/com/parse/RobolectricKeyStore.kt @@ -0,0 +1,33 @@ +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 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()) + } +}