Skip to content

Commit

Permalink
feat: Implementing encrypted local storage for user sessions with tests
Browse files Browse the repository at this point in the history
  • Loading branch information
rommansabbir committed Aug 6, 2024
1 parent 4653c28 commit 4d0494a
Show file tree
Hide file tree
Showing 11 changed files with 776 additions and 1 deletion.
2 changes: 2 additions & 0 deletions parse/build.gradle
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
110 changes: 110 additions & 0 deletions parse/src/main/java/com/parse/EncryptedFileObjectStore.java
Original file line number Diff line number Diff line change
@@ -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<T extends ParseObject> implements ParseObjectStore<T> {

private final String className;
private final File file;
private final EncryptedFile encryptedFile;
private final ParseObjectCurrentCoder coder;

public EncryptedFileObjectStore(Class<T> 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<T> getAsync() {
return Task.call(new Callable<T>() {
@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<Void> 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<Boolean> existsAsync() {
return Task.call(file::exists, ParseExecutors.io());
}

@Override
public Task<Void> deleteAsync() {
return Task.call(() -> {
if (file.exists() && !ParseFileUtils.deleteQuietly(file)) throw new RuntimeException("Unable to delete");
return null;
}, ParseExecutors.io());
}
}
5 changes: 4 additions & 1 deletion parse/src/main/java/com/parse/ParseCorePlugins.java
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ public ParseCurrentUserController getCurrentUserController() {
Parse.isLocalDatastoreEnabled()
? new OfflineObjectStore<>(ParseUser.class, PIN_CURRENT_USER, fileStore)
: fileStore;
ParseCurrentUserController controller = new CachedCurrentUserController(store);
EncryptedFileObjectStore<ParseUser> encryptedFileObjectStore = new EncryptedFileObjectStore<>(ParseUser.class, file, ParseUserCurrentCoder.get());
ParseObjectStoreMigrator<ParseUser> storeMigrator = new ParseObjectStoreMigrator<>(encryptedFileObjectStore, store);
ParseCurrentUserController controller = new CachedCurrentUserController(storeMigrator);
currentUserController.compareAndSet(null, controller);
currentUserController.compareAndSet(null, controller);
}
return currentUserController.get();
Expand Down
107 changes: 107 additions & 0 deletions parse/src/main/java/com/parse/ParseFileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 <code>null</code>
* @return the file contents, never <code>null</code>
* @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 <code>new FileInputStream(file)</code>.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
69 changes: 69 additions & 0 deletions parse/src/main/java/com/parse/ParseObjectStoreMigrator.java
Original file line number Diff line number Diff line change
@@ -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<T extends ParseObject> implements ParseObjectStore<T> {

private final ParseObjectStore<T> store;
private final ParseObjectStore<T> legacy;

/**
* @param store the new {@link ParseObjectStore} to migrate to
* @param legacy the old {@link ParseObjectStore} to migrate from
*/
public ParseObjectStoreMigrator(ParseObjectStore<T> store, ParseObjectStore<T> legacy) {
this.store = store;
this.legacy = legacy;
}

@Override
public Task<T> getAsync() {
return store.getAsync().continueWithTask(new Continuation<T, Task<T>>() {
@Override
public Task<T> then(Task<T> task) throws Exception {
if (task.getResult() != null) return task;
return legacy.getAsync().continueWithTask(new Continuation<T, Task<T>>() {
@Override
public Task<T> then(Task<T> 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<Void> setAsync(T object) {
return store.setAsync(object);
}

@Override
public Task<Boolean> existsAsync() {
return store.existsAsync().continueWithTask(new Continuation<Boolean, Task<Boolean>>() {
@Override
public Task<Boolean> then(Task<Boolean> task) throws Exception {
if (task.getResult()) return Task.forResult(true);
return legacy.existsAsync();
}
});
}

@Override
public Task<Void> deleteAsync() {
Task<Void> storeTask = store.deleteAsync();
return Task.whenAll(Arrays.asList(legacy.deleteAsync(), storeTask)).continueWithTask(new Continuation<Void, Task<Void>>() {
@Override
public Task<Void> then(Task<Void> task1) throws Exception {
return storeTask;
}
});
}
}
6 changes: 6 additions & 0 deletions parse/src/test/java/com/parse/AlgorithmParameterSpec.kt
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 4d0494a

Please sign in to comment.