Skip to content

Commit

Permalink
[MOBL-1776] Improved security by replacing SharedPreferences with Enc…
Browse files Browse the repository at this point in the history
…ryptedSharedPreferences to store UserInfo (#357)

* [MOBL-1811] Gradle = 8.6, AGP = 8.4.0, Kotlin = 1.9.23, targetSdkVersion = 34

* Reverting an unwanted version change

* first draft of encrypted shared pref implementation is ready

* Added tests for the get and save methods

* Added code for migration when needed.

* Added test cases to make sure the new preference is backward compatible

* Deprecated the clear(context) method

* Added additional test case

* Added configuration variable to opt in for encryption feature
  • Loading branch information
rahulrvp committed Jun 13, 2024
1 parent f587ee9 commit 0f826f2
Show file tree
Hide file tree
Showing 7 changed files with 288 additions and 11 deletions.
3 changes: 2 additions & 1 deletion android-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ android {
compileSdk 34

defaultConfig {
minSdkVersion 16
minSdkVersion 23
targetSdkVersion 34
multiDexEnabled true

Expand Down Expand Up @@ -53,6 +53,7 @@ dependencies {
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01'

implementation 'com.google.code.gson:gson:2.8.9'
implementation 'androidx.security:security-crypto:1.0.0'

// Firebase
implementation 'com.google.firebase:firebase-core:17.4.4'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.blueshift

import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Before
import org.junit.Test

class BlueshiftEncryptedPreferencesTest {
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
BlueshiftEncryptedPreferences.init(context)
}

@Test
fun savedValueIsReturnedWhenCorrectKeyIsProvided() {
BlueshiftEncryptedPreferences.putString(key = "key", value = "value")
val result = BlueshiftEncryptedPreferences.getString(key = "key", null)
assert(result == "value")
}

@Test
fun defaultValueIsReturnedWhenIncorrectKeyIsProvided() {
BlueshiftEncryptedPreferences.putString(key = "key", value = "value")
val result = BlueshiftEncryptedPreferences.getString(key = "wrong_key", "default")
assert(result == "default")
}

@Test
fun storedValueIsRemovedWhenCallingRemoveMethod() {
BlueshiftEncryptedPreferences.putString(key = "key", value = "value")

val result1 = BlueshiftEncryptedPreferences.getString(key = "key", null)
assert(result1 == "value")

BlueshiftEncryptedPreferences.remove(key = "key")

val result2 = BlueshiftEncryptedPreferences.getString(key = "key", null)
assert(result2 == null)
}

}
106 changes: 106 additions & 0 deletions android-sdk/src/androidTest/java/com/blueshift/model/UserInfoTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.blueshift.model

import android.content.Context
import android.content.SharedPreferences
import androidx.test.platform.app.InstrumentationRegistry
import com.blueshift.BlueshiftEncryptedPreferences
import org.junit.Before
import org.junit.Test

class UserInfoTest {
private lateinit var context: Context
private lateinit var legacyPreference: SharedPreferences

private fun oldPreferenceFile(context: Context): String {
return context.packageName + ".user_info_file"
}

private fun oldPreferenceKey(context: Context): String {
return context.packageName + ".user_info_key"
}

@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().targetContext

// Reset old preferences
legacyPreference = context.getSharedPreferences(
oldPreferenceFile(context), Context.MODE_PRIVATE
)
legacyPreference.edit().remove(oldPreferenceKey(context)).commit()

// Reset new preferences
BlueshiftEncryptedPreferences.init(context)
BlueshiftEncryptedPreferences.remove(PREF_KEY)
}

@Test
fun load_newInstall_returnEmptyUserInfoObject() {
// For a fresh installation, the shared preferences will not contain any data.
// So, the user info class should provide an instance without any value for its members.
val user = UserInfo.getInstance(context)
assert(user.name == null)
}

@Test
fun load_updatedFromOldSDK_returnSameUserInfoObject() {
// Mock the presence of a user object in the old preference.
legacyPreference.edit().putString(oldPreferenceKey(context), USER_JSON).apply()

// When encryption is not enabled, the user info class should provide the same value
// for its members as we saved in the old preference.
val userinfo = UserInfo.load(context, false)
assert(userinfo.name == JOHN)

// When encryption is not enabled, the user info class should provide the same value
// for its members as we saved in the old preference.
val userinfo2 = UserInfo.load(context, true)
assert(userinfo2.name == JOHN)

// Kill the existing instance for the next test.
UserInfo.killInstance()
}

@Test
fun load_updatedFromOldSDK_copiesTheContentOfOldPrefToNewPref() {
// Mock the presence of a user object in the old preference.
legacyPreference.edit().putString(oldPreferenceKey(context), USER_JSON).apply()

// case1 : When encryption is not enabled.
val userInfo = UserInfo.load(context, false)
assert(userInfo.name == JOHN)

// case2 : When encryption is enabled.
UserInfo.load(context, true)
val json = BlueshiftEncryptedPreferences.getString(PREF_KEY, null)
assert(USER_JSON == json)

// Kill the existing instance for the next test.
UserInfo.killInstance()
}

@Test
fun load_updatedFromOldSDK_deletesTheDataInOldPreference() {
// Mock the presence of a user object in the old preference.
legacyPreference.edit().putString(oldPreferenceKey(context), USER_JSON).apply()

// case1 : When encryption is not enabled.
val userInfo = UserInfo.load(context, false)
assert(userInfo.name == JOHN)

// case2 : When encryption is enabled.
UserInfo.load(context, true)
// Make sure the value stored in the old preferences is removed after copying it to the new preferences.
val legacyJson = legacyPreference.getString(oldPreferenceKey(context), null)
assert(legacyJson == null)

// Kill the existing instance for the next test.
UserInfo.killInstance()
}

companion object {
const val JOHN = "John"
const val PREF_KEY = "user_info"
const val USER_JSON = "{\"joined_at\":0,\"name\":\"$JOHN\",\"unsubscribed\":false}"
}
}
2 changes: 2 additions & 0 deletions android-sdk/src/main/java/com/blueshift/Blueshift.java
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ public void getLiveContentByCustomerId(@NonNull String slot, HashMap<String, Obj
public void initialize(@NonNull Configuration configuration) {
mConfiguration = configuration;

BlueshiftEncryptedPreferences.INSTANCE.init(mContext);

BlueshiftAttributesApp.getInstance().init(mContext);
doAppVersionChecks(mContext);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.blueshift

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys

object BlueshiftEncryptedPreferences {
private val PREF_NAME = "blueshift_sdk_preferences"

private lateinit var sharedPreferences: SharedPreferences

fun init(context: Context) {
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

sharedPreferences = EncryptedSharedPreferences.create(
PREF_NAME,
masterKey,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}

fun remove(key: String) {
if (::sharedPreferences.isInitialized) {
sharedPreferences.edit().remove(key).apply()
}
}

fun putString(key: String, value: String?) {
if (::sharedPreferences.isInitialized) {
sharedPreferences.edit().putString(key, value).apply()
}
}

fun getString(key: String, defaultValue: String?): String? {
return if (::sharedPreferences.isInitialized) {
sharedPreferences.getString(key, defaultValue)
} else {
defaultValue
}
}
}
12 changes: 12 additions & 0 deletions android-sdk/src/main/java/com/blueshift/model/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public class Configuration {
private Blueshift.DeviceIdSource deviceIdSource;
private String customDeviceId;

// Defines is we should store user info in plain text or in encrypted form.
// Default value is false to make it backward compatible.
private boolean shouldEncryptUserInfo = false;

public Configuration() {
// Setting default region to the US.
region = BlueshiftRegion.US;
Expand Down Expand Up @@ -94,6 +98,14 @@ public Configuration() {
autoAppOpenInterval = 86400;
}

public boolean shouldEncryptUserInfo() {
return shouldEncryptUserInfo;
}

public void setShouldEncryptUserInfo(boolean shouldEncryptUserInfo) {
this.shouldEncryptUserInfo = shouldEncryptUserInfo;
}

public boolean isPushAppLinksEnabled() {
return pushAppLinksEnabled;
}
Expand Down
91 changes: 81 additions & 10 deletions android-sdk/src/main/java/com/blueshift/model/UserInfo.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.blueshift.model;

import android.content.Context;
import android.content.SharedPreferences;

import androidx.annotation.NonNull;

import com.blueshift.BlueshiftConstants;
import com.blueshift.BlueshiftEncryptedPreferences;
import com.blueshift.BlueshiftLogger;
import com.blueshift.util.BlueshiftUtils;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;

Expand All @@ -23,6 +28,7 @@ public class UserInfo {
private static final String TAG = "UserInfo";
private static final String PREF_FILE = "user_info_file";
private static final String PREF_KEY = "user_info_key";
private static final String PREF_KEY_ENCRYPTED = "user_info";
private static final Boolean lock = false;

private String email;
Expand All @@ -49,6 +55,10 @@ private UserInfo() {
unsubscribed = false;
}

static void killInstance() {
instance = null;
}

public static UserInfo getInstance(Context context) {
synchronized (lock) {
if (instance == null) {
Expand All @@ -60,18 +70,29 @@ public static UserInfo getInstance(Context context) {
}
}

private static String getPrefFile(Context context) {
private static String getLegacyPreferenceFile(@NonNull Context context) {
return context.getPackageName() + "." + PREF_FILE;
}

private static String getPrefKey(Context context) {
private static String getLegacyPreferenceKey(@NonNull Context context) {
return context.getPackageName() + "." + PREF_KEY;
}

private static UserInfo load(Context context) {
private static UserInfo load(@NonNull Context context) {
Configuration configuration = BlueshiftUtils.getConfiguration(context);
boolean isEncryptionEnabled = configuration != null && configuration.shouldEncryptUserInfo();
return load(context, isEncryptionEnabled);
}

static UserInfo load(Context context, boolean encryptionEnabled) {
return encryptionEnabled ? loadEncrypted(context) : loadLegacy(context);
}

private static UserInfo loadLegacy(@NonNull Context context) {
UserInfo userInfo = null;
String json = context.getSharedPreferences(getPrefFile(context), Context.MODE_PRIVATE)
.getString(getPrefKey(context), null);

SharedPreferences preferences = context.getSharedPreferences(getLegacyPreferenceFile(context), Context.MODE_PRIVATE);
String json = preferences.getString(getLegacyPreferenceKey(context), null);
if (json != null) {
try {
userInfo = new Gson().fromJson(json, UserInfo.class);
Expand All @@ -83,6 +104,38 @@ private static UserInfo load(Context context) {
return userInfo;
}

private static UserInfo loadEncrypted(@NonNull Context context) {
UserInfo userInfo = null;

String json = BlueshiftEncryptedPreferences.INSTANCE.getString(PREF_KEY_ENCRYPTED, null);
if (json == null) {
// The new secure store doesn't have the user info. Let's check in the old preference
// file and copy over the data if present.
SharedPreferences pref = context.getSharedPreferences(getLegacyPreferenceFile(context), Context.MODE_PRIVATE);
String legacyJson = pref.getString(getLegacyPreferenceKey(context), null);
if (legacyJson != null) {
try {
userInfo = new Gson().fromJson(legacyJson, UserInfo.class);
// Save it to secure store for loading next time.
userInfo.saveEncrypted();
// Clear the old preference for privacy reasons.
pref.edit().clear().apply();
} catch (Exception e) {
BlueshiftLogger.e(TAG, e);
}
}
} else {
// The new secure store has the user info. Let's load it.
try {
userInfo = new Gson().fromJson(json, UserInfo.class);
} catch (Exception e) {
BlueshiftLogger.e(TAG, e);
}
}

return userInfo;
}

public HashMap<String, Object> toHashMap() {
HashMap<String, Object> map = new HashMap<>();
map.put(BlueshiftConstants.KEY_CUSTOMER_ID, getRetailerCustomerId());
Expand Down Expand Up @@ -116,11 +169,29 @@ public HashMap<String, Object> toHashMap() {
return map;
}

public void save(Context context) {
context.getSharedPreferences(getPrefFile(context), Context.MODE_PRIVATE)
.edit()
.putString(getPrefKey(context), new Gson().toJson(this))
.apply();
public void save(@NonNull Context context) {
Configuration configuration = BlueshiftUtils.getConfiguration(context);
boolean isEncryptionEnabled = configuration != null && configuration.shouldEncryptUserInfo();
save(context, isEncryptionEnabled);
}

void save(Context context, boolean encryptionEnabled) {
if (encryptionEnabled) {
saveEncrypted();
} else {
saveLegacy(context);
}
}

private void saveLegacy(Context context) {
String json = new Gson().toJson(this);
context.getSharedPreferences(getLegacyPreferenceFile(context), Context.MODE_PRIVATE)
.edit().putString(getLegacyPreferenceKey(context), json).apply();
}

private void saveEncrypted() {
String json = new Gson().toJson(this);
BlueshiftEncryptedPreferences.INSTANCE.putString(PREF_KEY_ENCRYPTED, json);
}

public String getEmail() {
Expand Down

0 comments on commit 0f826f2

Please sign in to comment.