Skip to content

feat: Implementing encrypted local storage for user sessions #1191

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

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
94 changes: 94 additions & 0 deletions parse/src/main/java/com/parse/EncryptedFileObjectStore.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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;

public class EncryptedFileObjectStore<T extends ParseObject> implements ParseObjectStore<T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you add Javadoc on the public classes and their public methods you have introduced?


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();
}

private void saveToDisk(ParseObject current) throws IOException, GeneralSecurityException {
JSONObject json = coder.encode(current.getState(), null, PointerEncoder.get());
ParseFileUtils.writeJSONObjectToFile(encryptedFile, json);
}

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 | JSONException | IOException e) {
throw new RuntimeException(e);
}
}
}, 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 (IOException | GeneralSecurityException e) {
throw new RuntimeException(e);
}
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
74 changes: 72 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,31 @@ public static String readFileToString(File file, String encoding) throws IOExcep
return readFileToString(file, Charset.forName(encoding));
}

public static String readFileToString(EncryptedFile file, Charset encoding) throws IOException, GeneralSecurityException {
return new String(readFileToByteArray(file), encoding);
}

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));
}

public static void writeStringToFile(EncryptedFile file, String string, Charset encoding)
throws IOException, GeneralSecurityException {
writeByteArrayToFile(file, string.getBytes(encoding));
}

public static void writeStringToFile(EncryptedFile file, String string, String encoding)
throws IOException, GeneralSecurityException {
writeStringToFile(file, string, Charset.forName(encoding));
}

Expand All @@ -559,5 +618,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 not create the file if it does not exist. */
public static void writeJSONObjectToFile(EncryptedFile file, JSONObject json) throws IOException, GeneralSecurityException {
ParseFileUtils.writeByteArrayToFile(file, json.toString().getBytes("UTF-8"));
}

// endregion
}
71 changes: 71 additions & 0 deletions parse/src/main/java/com/parse/ParseObjectStoreMigrator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.parse;

import com.parse.boltsinternal.Continuation;
import com.parse.boltsinternal.Task;

import java.util.Arrays;

public class ParseObjectStoreMigrator<T extends ParseObject> implements ParseObjectStore<T> {
Copy link
Contributor

Choose a reason for hiding this comment

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

You could add some Javadoc with comments.

Does this migrator needs to be here forever or just for about some migration period?

Copy link
Author

@ghost ghost Mar 19, 2023

Choose a reason for hiding this comment

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

Wow @DrMimik! Many thanks for this contribution. It's highly appreciated! Looks very clean and nice!

I have the feeling that this is something working for a long time somewhere else, correct me if I'm wrong. I'm guessing that because of the copyright comments.

I left just few questions and small javadoc suggestions. I'm wondering @mtrezza if the migration mechanism should be here forever or there is some policy about that. But if @DrMimik say that already migrated stores are covered and there's no performance issue maybe can stay for a while.

Thank you guys for creating this awesome platform, I actuallty just copied the ParseObjectStore's logic for creating a new ObjectStore and used Jetpack's crypto library.

Since Robolectric doesn't mock Android's java.security.KeyStore I copied a couple files from this repo (with their copyright XD ) to create tests using Robolectric.

I am actually in a learning process, so please don't hesitate to edit or comment on anything in the code.

Copy link
Author

@ghost ghost Mar 19, 2023

Choose a reason for hiding this comment

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

We don't have a specific policy for the duration of providing migration mechanisms. It depends on the type of change. In this case I'd see the mechanism staying for several years, so indefinite at this point. The reason is that it can be a years long process to migrate clientes once they are released to end-users.

I think the migrator should be available for old users until they migrate, or remove it on a major release.


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

public ParseObjectStoreMigrator(ParseObjectStore<T> store, ParseObjectStore<T> legacy) {
this.store = store;
this.legacy = legacy;
}

private static <T extends ParseObject> Task<T> migrate(
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a way to check whenever migrate is needed?

Copy link
Contributor

Choose a reason for hiding this comment

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

If from is deleted and next time you call migrate the onSuccessTask what will happen? It will call again onSuccessTask? Is there chance onFailedTask or something similar to be called in such case?

Copy link
Author

@ghost ghost Mar 19, 2023

Choose a reason for hiding this comment

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

Thanks for pointing that out, that is actually a bug which is going to be calling from.getAsync() recursively, I'll fix it ASAP.

final ParseObjectStore<T> from, final ParseObjectStore<T> to) {
return from.getAsync()
.onSuccessTask(
task -> {
final T object = task.getResult();
if (object == null) {
return task;
}

return Task.whenAll(
Arrays.asList(from.deleteAsync(), to.setAsync(object)))
.continueWith(task1 -> object);
});
}

@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 migrate(legacy, store);
}
});
}

@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