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

[MOBL-1776] Improved security by replacing SharedPreferences with EncryptedSharedPreferences to store UserInfo #357

Merged
merged 9 commits into from
Jun 4, 2024
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 @@ -352,6 +352,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
Loading