Skip to content

feat: Implementing encrypted local storage for user sessions with tests #1211

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 12 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ public boolean isCancellationRequested() {
}
}

/** @return the token that can be passed to asynchronous method to control cancellation. */
Copy link
Member

@mtrezza mtrezza Oct 5, 2024

Choose a reason for hiding this comment

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

Please revert these unrelated changes in bolts, twitter and wherever you find these comment-only refactors.

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay.

/**
* @return the token that can be passed to asynchronous method to control cancellation.
*/
public CancellationToken getToken() {
synchronized (lock) {
throwIfClosed();
Expand Down
16 changes: 12 additions & 4 deletions bolts-tasks/src/main/java/com/parse/boltsinternal/Task.java
Original file line number Diff line number Diff line change
Expand Up @@ -541,28 +541,36 @@ public boolean isCompleted() {
}
}

/** @return {@code true} if the task was cancelled, {@code false} otherwise. */
/**
* @return {@code true} if the task was cancelled, {@code false} otherwise.
*/
public boolean isCancelled() {
synchronized (lock) {
return cancelled;
}
}

/** @return {@code true} if the task has an error, {@code false} otherwise. */
/**
* @return {@code true} if the task has an error, {@code false} otherwise.
*/
public boolean isFaulted() {
synchronized (lock) {
return getError() != null;
}
}

/** @return The result of the task, if set. {@code null} otherwise. */
/**
* @return The result of the task, if set. {@code null} otherwise.
*/
public TResult getResult() {
synchronized (lock) {
return result;
}
}

/** @return The error for the task, if set. {@code null} otherwise. */
/**
* @return The error for the task, if set. {@code null} otherwise.
*/
public Exception getError() {
synchronized (lock) {
if (error != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ public TaskCompletionSource() {
task = new Task<>();
}

/** @return the Task associated with this TaskCompletionSource. */
/**
* @return the Task associated with this TaskCompletionSource.
*/
public Task<TResult> getTask() {
return task;
}
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ buildscript {
classpath "org.jacoco:org.jacoco.core:$jacocoVersion"
classpath "com.dicedmelon.gradle:jacoco-android:0.1.5"
classpath "io.freefair.gradle:android-gradle-plugins:4.2.0-m1"
classpath "com.diffplug.spotless:spotless-plugin-gradle:5.17.1"
classpath "com.diffplug.spotless:spotless-plugin-gradle:6.7.1"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix version inconsistency with Spotless plugin.

The classpath version is updated to 6.7.1, but the plugin declaration on line 20 still uses version 5.17.1. This inconsistency could cause build issues.

Apply this fix to align the plugin version:

plugins {
    id "com.github.ben-manes.versions" version "0.28.0"
-    id "com.diffplug.spotless" version "5.17.1"
+    id "com.diffplug.spotless" version "6.7.1"
}
🤖 Prompt for AI Agents
In build.gradle at line 14 and around line 20, the Spotless plugin version is
inconsistent, with the classpath using 6.7.1 and the plugin declaration using
5.17.1. Update the plugin declaration on line 20 to also use version 6.7.1 to
ensure both references match and prevent build issues.

}
}

Expand Down
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"
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify the alpha version dependency for production readiness.

Using an alpha version (1.1.0-alpha03) of androidx.security:security-crypto in production raises concerns about stability and security. Alpha versions are typically not recommended for production use.


🌐 Web query:

What is the latest stable version of androidx.security:security-crypto and are there any known security vulnerabilities in version 1.1.0-alpha03?

💡 Result:

The latest stable version of androidx.security:security-crypto is 1.0.0, which was released on April 21, 2021[2][4]. Since then, the library has continued with alpha releases in the 1.1.0 series, with the most recent being version 1.1.0-alpha07, released on April 9, 2025[1][2].

Notably, in version 1.1.0-alpha07, all APIs in the library were deprecated in favor of existing platform APIs and direct use of Android Keystore[1]. This deprecation was officially implemented in April 2025, as mentioned in multiple sources[1][7][9].

Regarding version 1.1.0-alpha03 specifically, it does have a known security vulnerability. According to Maven Repository information, this version has a vulnerability inherited from its dependencies, specifically CVE-2022-24329[6]. This vulnerability is related to the kotlin-stdlib dependency that version 1.1.0-alpha03 uses[6].

Additionally, there have been reports of crashes in certain versions of the library. Some users reported that version 1.1.0-alpha01 was the last properly working version before encountering issues[8]. These crashes were particularly problematic in applications that had allowBackUp enabled[8].

If you're currently using this library, you should consider migrating to platform APIs and Android Keystore directly, as recommended by the deprecation notice in the latest version[1]. For those who still need the functionality, some developers have created forks of the library to provide ongoing support post-deprecation[7].

Citations:


Review production readiness of androidx.security:security-crypto:1.1.0-alpha03

Using an alpha release in production introduces stability and security risks. Version 1.1.0-alpha03 is known to inherit CVE-2022-24329 from its Kotlin StdLib dependency and the entire 1.1.0 series has since been deprecated in favor of platform APIs (Android Keystore). The latest stable release is 1.0.0 (Apr 2021).

• File: parse/build.gradle
Line: 54

Suggested changes:

- implementation "androidx.security:security-crypto:1.1.0-alpha03"
+ // Consider migrating to Android Keystore or use the latest stable library
+ implementation "androidx.security:security-crypto:1.0.0"

If you require features beyond 1.0.0, either fork a maintained version or switch to direct platform APIs as recommended in the April 2025 deprecation notice.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
implementation "androidx.security:security-crypto:1.1.0-alpha03"
// Consider migrating to Android Keystore or use the latest stable library
implementation "androidx.security:security-crypto:1.0.0"
🤖 Prompt for AI Agents
In parse/build.gradle at line 54, the dependency on
androidx.security:security-crypto uses the alpha version 1.1.0-alpha03, which
has known security vulnerabilities and is deprecated. Replace this dependency
with the latest stable version 1.0.0 for production use, or alternatively,
remove this dependency and migrate to using direct platform APIs and Android
Keystore as recommended in the April 2025 deprecation notice. If features beyond
1.0.0 are needed, consider using a maintained fork instead of the alpha release.


testImplementation "org.junit.jupiter:junit-jupiter:$rootProject.ext.jupiterVersion"
testImplementation "org.skyscreamer:jsonassert:1.5.0"
Expand Down
135 changes: 135 additions & 0 deletions parse/src/main/java/com/parse/EncryptedFileObjectStore.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.parse;

import android.content.Context;
import androidx.security.crypto.EncryptedFile;
import androidx.security.crypto.MasterKey;
import com.parse.boltsinternal.Task;
import java.io.File;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.concurrent.Callable;
import org.json.JSONException;
import org.json.JSONObject;

/**
* 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());
}
Comment on lines +44 to +46
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Include the original exception when wrapping.

Wrapping exceptions without including the original cause makes debugging difficult. The stack trace and original exception details are lost.

Apply this fix:

-        } catch (GeneralSecurityException | IOException e) {
-            throw new RuntimeException(e.getMessage());
+        } catch (GeneralSecurityException | IOException e) {
+            throw new RuntimeException("Failed to initialize encrypted file", e);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (GeneralSecurityException | IOException e) {
throw new RuntimeException(e.getMessage());
}
} catch (GeneralSecurityException | IOException e) {
throw new RuntimeException("Failed to initialize encrypted file", e);
}
🤖 Prompt for AI Agents
In parse/src/main/java/com/parse/EncryptedFileObjectStore.java around lines 44
to 46, the catch block wraps exceptions by only passing the message to
RuntimeException, losing the original exception details. Modify the throw
statement to include the caught exception as the cause by passing the exception
object itself to the RuntimeException constructor, preserving the stack trace
and original exception information.

}

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());
}
Comment on lines +96 to +98
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Include the original exception when wrapping.

Same issue as above - include the original exception for better debugging.

Apply this fix:

-                        } catch (GeneralSecurityException e) {
-                            throw new RuntimeException(e.getMessage());
+                        } catch (GeneralSecurityException e) {
+                            throw new RuntimeException("Failed to decrypt file", e);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (GeneralSecurityException e) {
throw new RuntimeException(e.getMessage());
}
} catch (GeneralSecurityException e) {
throw new RuntimeException("Failed to decrypt file", e);
}
🤖 Prompt for AI Agents
In parse/src/main/java/com/parse/EncryptedFileObjectStore.java around lines 96
to 98, the catch block wraps a GeneralSecurityException by throwing a new
RuntimeException with only the message, losing the original exception details.
Modify the RuntimeException constructor to include the caught exception as the
cause by passing the original exception object as the second argument,
preserving the stack trace for better debugging.

}
},
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());
}
Comment on lines +108 to +114
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Improve error messages and exception handling.

The error messages lack context, and exceptions are wrapped without the original cause.

Apply these improvements:

-                    if (file.exists() && !ParseFileUtils.deleteQuietly(file))
-                        throw new RuntimeException("Unable to delete");
+                    if (file.exists() && !ParseFileUtils.deleteQuietly(file))
+                        throw new RuntimeException("Unable to delete file: " + file.getAbsolutePath());
                     try {
                         saveToDisk(object);
                     } catch (GeneralSecurityException e) {
-                        throw new RuntimeException(e.getMessage());
+                        throw new RuntimeException("Failed to encrypt and save object", e);
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (file.exists() && !ParseFileUtils.deleteQuietly(file))
throw new RuntimeException("Unable to delete");
try {
saveToDisk(object);
} catch (GeneralSecurityException e) {
throw new RuntimeException(e.getMessage());
}
if (file.exists() && !ParseFileUtils.deleteQuietly(file))
throw new RuntimeException("Unable to delete file: " + file.getAbsolutePath());
try {
saveToDisk(object);
} catch (GeneralSecurityException e) {
throw new RuntimeException("Failed to encrypt and save object", e);
}
🤖 Prompt for AI Agents
In parse/src/main/java/com/parse/EncryptedFileObjectStore.java around lines 108
to 114, improve error handling by enhancing the exception messages with more
context and by wrapping exceptions with their original cause instead of just the
message. Modify the RuntimeException thrown when file deletion fails to include
the file path or name for clarity. When catching GeneralSecurityException, wrap
it in a RuntimeException passing the original exception as the cause to preserve
the stack trace and debugging information.

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");
Comment on lines +129 to +130
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve error message with file context.

Apply this improvement:

-                    if (file.exists() && !ParseFileUtils.deleteQuietly(file))
-                        throw new RuntimeException("Unable to delete");
+                    if (file.exists() && !ParseFileUtils.deleteQuietly(file))
+                        throw new RuntimeException("Unable to delete file: " + file.getAbsolutePath());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (file.exists() && !ParseFileUtils.deleteQuietly(file))
throw new RuntimeException("Unable to delete");
if (file.exists() && !ParseFileUtils.deleteQuietly(file))
throw new RuntimeException("Unable to delete file: " + file.getAbsolutePath());
🤖 Prompt for AI Agents
In parse/src/main/java/com/parse/EncryptedFileObjectStore.java around lines 129
to 130, the RuntimeException thrown when file deletion fails has a generic
message. Update the exception message to include the file path or name to
provide context about which file could not be deleted, improving error
traceability.

return null;
},
ParseExecutors.io());
}
}
4 changes: 3 additions & 1 deletion parse/src/main/java/com/parse/ManifestInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ private static ApplicationInfo getApplicationInfo(Context context, int flags) {
}
}

/** @return A {@link Bundle} if meta-data is specified in AndroidManifest, otherwise null. */
/**
* @return A {@link Bundle} if meta-data is specified in AndroidManifest, otherwise null.
*/
public static Bundle getApplicationMetadata(Context context) {
ApplicationInfo info = getApplicationInfo(context, PackageManager.GET_META_DATA);
if (info != null) {
Expand Down
4 changes: 3 additions & 1 deletion parse/src/main/java/com/parse/Parse.java
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,9 @@ public static void destroy() {
allowCustomObjectId = false;
}

/** @return {@code True} if {@link #initialize} has been called, otherwise {@code false}. */
/**
* @return {@code True} if {@link #initialize} has been called, otherwise {@code false}.
*/
static boolean isInitialized() {
return ParsePlugins.get() != null;
}
Expand Down
4 changes: 3 additions & 1 deletion parse/src/main/java/com/parse/ParseClassName.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
@Inherited
@Documented
public @interface ParseClassName {
/** @return The Parse class name associated with the ParseObject subclass. */
/**
* @return The Parse class name associated with the ParseObject subclass.
*/
String value();
}
7 changes: 6 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,12 @@ 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
Loading
Loading