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

[Android] Replace SharedPreferences with DataStore Preferences #629

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
2 changes: 1 addition & 1 deletion KeychainExample/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:7.1.1")
classpath("com.android.tools.build:gradle:7.1.3")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("de.undercouch:gradle-download-task:5.0.1")
// NOTE: Do not place your application dependencies here; they belong
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ The module will automatically use the appropriate CipherStorage implementation b
- API level 16-22 will en/de crypt using Facebook Conceal
- API level 23+ will en/de crypt using Android Keystore

Encrypted data is stored in SharedPreferences.
Encrypted data is stored in DataStore Preferences.

The `setInternetCredentials(server, username, password)` call will be resolved as call to `setGenericPassword(username, password, server)`. Use the `server` argument to distinguish between multiple entries.

Expand Down Expand Up @@ -510,7 +510,7 @@ This package supports visionOS.

### Security

On API levels that do not support Android keystore, Facebook Conceal is used to en/decrypt stored data. The encrypted data is then stored in SharedPreferences. Since Conceal itself stores its encryption key in SharedPreferences, it follows that if the device is rooted (or if an attacker can somehow access the filesystem), the key can be obtained and the stored data can be decrypted. Therefore, on such a device, the conceal encryption is only an obscurity. On API level 23+ the key is stored in the Android Keystore, which makes the key non-exportable and therefore makes the entire process more secure. Follow best practices and do not store user credentials on a device. Instead use tokens or other forms of authentication and re-ask for user credentials before performing sensitive operations.
On API levels that do not support Android keystore, Facebook Conceal is used to en/decrypt stored data. The encrypted data is then stored in DataStore Preferences. Since Conceal itself stores its encryption key in DataStore Preferences, it follows that if the device is rooted (or if an attacker can somehow access the filesystem), the key can be obtained and the stored data can be decrypted. Therefore, on such a device, the conceal encryption is only an obscurity. On API level 23+ the key is stored in the Android Keystore, which makes the key non-exportable and therefore makes the entire process more secure. Follow best practices and do not store user credentials on a device. Instead use tokens or other forms of authentication and re-ask for user credentials before performing sensitive operations.

![Android Security Framework](https://source.android.com/security/images/authentication-flow.png)

Expand Down
6 changes: 6 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ buildscript {

apply plugin: 'com.android.library'
apply plugin: "com.adarshr.test-logger"
apply plugin: 'org.jetbrains.kotlin.android'

def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
Expand Down Expand Up @@ -59,6 +60,8 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'

implementation(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}"))

// https://mvnrepository.com/artifact/androidx.biometric/biometric
implementation 'androidx.biometric:biometric:1.1.0@aar'

Expand All @@ -74,6 +77,9 @@ dependencies {
https://github.com/facebook/conceal/releases */
implementation "com.facebook.conceal:conceal:1.1.3@aar"

// Used to store encrypted data
implementation("androidx.datastore:datastore-preferences:1.0.0")

/* Unit Testing Frameworks */
testImplementation 'junit:junit:4.13.2'

Expand Down
118 changes: 118 additions & 0 deletions android/src/main/java/com/oblador/keychain/DataStorePrefsStorage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.oblador.keychain

import android.content.Context
import android.util.Base64
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.facebook.react.bridge.ReactApplicationContext
import com.oblador.keychain.KeychainModule.KnownCiphers
import com.oblador.keychain.PrefsStorageBase.KEYCHAIN_DATA
import com.oblador.keychain.PrefsStorageBase.ResultSet
import com.oblador.keychain.PrefsStorageBase.getKeyForCipherStorage
import com.oblador.keychain.PrefsStorageBase.getKeyForPassword
import com.oblador.keychain.PrefsStorageBase.getKeyForUsername
import com.oblador.keychain.PrefsStorageBase.isKeyForCipherStorage
import com.oblador.keychain.cipherStorage.CipherStorage
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

@Suppress("unused")
class DataStorePrefsStorage(reactContext: ReactApplicationContext) : PrefsStorageBase {

private val Context.prefs: DataStore<Preferences> by preferencesDataStore(
name = KEYCHAIN_DATA,
produceMigrations = ::sharedPreferencesMigration
)
private val prefs: DataStore<Preferences> = reactContext.prefs
private val prefsData: Preferences get() = callSuspendable { prefs.data.first() }

private fun sharedPreferencesMigration(context: Context): List<DataMigration<Preferences>> {
return listOf(SharedPreferencesMigration(context, KEYCHAIN_DATA))
}

override fun getEncryptedEntry(service: String): ResultSet? {
val bytesForUsername = getBytesForUsername(service)
val bytesForPassword = getBytesForPassword(service)
var cipherStorageName = getCipherStorageName(service)

// in case of wrong password or username
if (bytesForUsername == null || bytesForPassword == null) return null
if (cipherStorageName == null) {
// If the CipherStorage name is not found, we assume it is because the entry was written by an older
// version of this library. The older version used Facebook Conceal, so we default to that.
cipherStorageName = KnownCiphers.FB
}
return ResultSet(cipherStorageName, bytesForUsername, bytesForPassword)
}

override fun removeEntry(service: String) {
val keyForUsername = stringPreferencesKey(getKeyForUsername(service))
val keyForPassword = stringPreferencesKey(getKeyForPassword(service))
val keyForCipherStorage = stringPreferencesKey(getKeyForCipherStorage(service))
callSuspendable {
prefs.edit {
it.remove(keyForUsername)
it.remove(keyForPassword)
it.remove(keyForCipherStorage)
}
}
}

override fun storeEncryptedEntry(
service: String,
encryptionResult: CipherStorage.EncryptionResult,
) {
val keyForUsername = stringPreferencesKey(getKeyForUsername(service))
val keyForPassword = stringPreferencesKey(getKeyForPassword(service))
val keyForCipherStorage = stringPreferencesKey(getKeyForCipherStorage(service))
callSuspendable {
prefs.edit {
it[keyForUsername] = Base64.encodeToString(encryptionResult.username, Base64.DEFAULT)
it[keyForPassword] = Base64.encodeToString(encryptionResult.password, Base64.DEFAULT)
it[keyForCipherStorage] = encryptionResult.cipherName
}
}
}

override fun getUsedCipherNames(): Set<String?> {
val result: MutableSet<String?> = HashSet()
val keys = prefsData.asMap().keys.map { it.name }
for (key in keys) {
if (isKeyForCipherStorage(key)) {
val cipher = prefsData[stringPreferencesKey(key)]
result.add(cipher)
}
}
return result
}

private fun <T> callSuspendable(block: suspend () -> T): T {
return runBlocking {
block()
}
}

private fun getBytesForUsername(service: String): ByteArray? {
val key = stringPreferencesKey(getKeyForUsername(service))
return getBytes(key)
}

private fun getBytesForPassword(service: String): ByteArray? {
val key = stringPreferencesKey(getKeyForPassword(service))
return getBytes(key)
}

private fun getCipherStorageName(service: String): String? {
val key = stringPreferencesKey(getKeyForCipherStorage(service))
return prefsData[key]
}

private fun getBytes(prefKey: Preferences.Key<String>): ByteArray? {
return prefsData[prefKey]?.let { Base64.decode(it, Base64.DEFAULT) }
}
}
12 changes: 7 additions & 5 deletions android/src/main/java/com/oblador/keychain/KeychainModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringDef;
import androidx.annotation.VisibleForTesting;
import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricPrompt.PromptInfo;

Expand All @@ -17,7 +18,7 @@
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.oblador.keychain.PrefsStorage.ResultSet;
import com.oblador.keychain.PrefsStorageBase.ResultSet;
import com.oblador.keychain.cipherStorage.CipherStorage;
import com.oblador.keychain.cipherStorage.CipherStorage.DecryptionResult;
import com.oblador.keychain.cipherStorage.CipherStorage.EncryptionResult;
Expand Down Expand Up @@ -129,16 +130,17 @@ public class KeychainModule extends ReactContextBaseJavaModule {
//region Members
/** Name-to-instance lookup map. */
private final Map<String, CipherStorage> cipherStorageMap = new HashMap<>();
/** Shared preferences storage. */
private final PrefsStorage prefsStorage;
/** Preferences storage. */
@VisibleForTesting
public final PrefsStorageBase prefsStorage;
//endregion

//region Initialization

/** Default constructor. */
public KeychainModule(@NonNull final ReactApplicationContext reactContext) {
super(reactContext);
prefsStorage = new PrefsStorage(reactContext);
prefsStorage = new DataStorePrefsStorage(reactContext);

addCipherStorageToMap(new CipherStorageFacebookConceal(reactContext));
addCipherStorageToMap(new CipherStorageKeystoreAesCbc());
Expand Down Expand Up @@ -380,7 +382,7 @@ protected void resetGenericPassword(@NonNull final String alias,
cipherStorage.removeKey(alias);
}
}
// And then we remove the entry in the shared preferences
// And then we remove the entry in the preferences
prefsStorage.removeEntry(alias);

promise.resolve(true);
Expand Down
157 changes: 0 additions & 157 deletions android/src/main/java/com/oblador/keychain/PrefsStorage.java

This file was deleted.