Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implementing encrypted local storage for user sessions #1191

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
113 changes: 113 additions & 0 deletions parse/src/main/java/com/parse/EncryptedFileObjectStore.java
Original file line number Diff line number Diff line change
@@ -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<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());
}
}
4 changes: 3 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,9 @@ 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);
}
return currentUserController.get();
Expand Down
105 changes: 103 additions & 2 deletions parse/src/main/java/com/parse/ParseFileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 <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);
}
}

// -----------------------------------------------------------------------

/**
Expand Down Expand Up @@ -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);
}
}

// -----------------------------------------------------------------------

/**
Expand Down Expand Up @@ -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));
}

Expand All @@ -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
}
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;
}
});
}
}
22 changes: 22 additions & 0 deletions parse/src/test/java/com/parse/AlgorithmParameterSpecExt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.parse

/*
* Copyright 2020 Appmattus Limited
Copy link
Member

@mtrezza mtrezza Mar 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure to consider the license implications. This repository is planned to be migrated to Apache 2, we should do that before merging this, otherwise we need to add this as a second license.

*
* 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
Loading