Skip to content

Commit

Permalink
feat: replace SharedPreferences with DataStore Preferences (#629)
Browse files Browse the repository at this point in the history
* Extract PrefsStorageBase interface

* Add kotlin support

* Replace shared preferences with preferences data store

* Add support to migrate from sharedPrefs

* Test migration from sharedPrefs to dataStore

* Remove todo from DataStorePrefsStorage

* Remove PrefsStorage in favour of DataStorePrefsStorage

* Replace mentions of shared preferences with preferences

* Update android gradle plugin

* Downgrade kotlin version and add it to implementation deps

* fix: React Methods region

* refactor: Convert PrefsStorageBase to Kotlin & use DataStorePrefsStorage

* fix: Use module coroutineScope in prefs data store

* chore: Update datastore-preferences & clean diff

* Remove shared preferences

* fix: Use Mutex to ensure no concurrent encryption operations

* fix: mutex

---------

Co-authored-by: Dorian Mazur <[email protected]>
Co-authored-by: MazurDorian <[email protected]>
  • Loading branch information
3 people authored Nov 11, 2024
1 parent 44ccaa0 commit c9e2322
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 134 deletions.
4 changes: 3 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ dependencies {
/* version higher 1.1.3 has problems with included soloader packages,
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.1.1")
}

if (isNewArchitectureEnabled()) {
Expand All @@ -91,4 +94,3 @@ if (isNewArchitectureEnabled()) {
codegenJavaPackageName = "com.oblador.keychain"
}
}

123 changes: 123 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,123 @@
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.Companion.KEYCHAIN_DATA
import com.oblador.keychain.PrefsStorageBase.Companion.getKeyForCipherStorage
import com.oblador.keychain.PrefsStorageBase.Companion.getKeyForPassword
import com.oblador.keychain.PrefsStorageBase.Companion.getKeyForUsername
import com.oblador.keychain.PrefsStorageBase.Companion.isKeyForCipherStorage
import com.oblador.keychain.PrefsStorageBase.ResultSet
import com.oblador.keychain.cipherStorage.CipherStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

class DataStorePrefsStorage(
reactContext: ReactApplicationContext,
private val coroutineScope: CoroutineScope,
) : PrefsStorageBase {

private val Context.prefs: DataStore<Preferences> by preferencesDataStore(
name = KEYCHAIN_DATA,
produceMigrations = ::sharedPreferencesMigration,
scope = coroutineScope,
)
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 which 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 val usedCipherNames: Set<String?>
get() {
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(coroutineScope.coroutineContext) {
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) }
}
}
22 changes: 14 additions & 8 deletions android/src/main/java/com/oblador/keychain/KeychainModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

@ReactModule(name = KeychainModule.KEYCHAIN_MODULE)
@Suppress("unused")
Expand Down Expand Up @@ -134,16 +136,19 @@ class KeychainModule(reactContext: ReactApplicationContext) :
private val cipherStorageMap: MutableMap<String, CipherStorage> = HashMap()

/** Shared preferences storage. */
private val prefsStorage: PrefsStorage
private val prefsStorage: PrefsStorageBase

/** Launches a coroutine to perform non-blocking UI operations */
private val coroutineScope = CoroutineScope(Dispatchers.Default)

/** Mutex to prevent concurrent calls to Cipher, which doesn't support multi-threading */
private val mutex = Mutex()

// endregion
// region Initialization
/** Default constructor. */
init {
prefsStorage = PrefsStorage(reactContext)
prefsStorage = DataStorePrefsStorage(reactContext, coroutineScope)
addCipherStorageToMap(CipherStorageFacebookConceal(reactContext))
addCipherStorageToMap(CipherStorageKeystoreAesCbc(reactContext))
addCipherStorageToMap(CipherStorageKeystoreAesGcm(reactContext, false))
Expand Down Expand Up @@ -199,8 +204,8 @@ class KeychainModule(reactContext: ReactApplicationContext) :
constants[SecurityLevel.SECURE_HARDWARE.jsName()] = SecurityLevel.SECURE_HARDWARE.name
return constants
}

// endregion

// region React Methods
private fun setGenericPassword(
alias: String,
Expand All @@ -216,7 +221,7 @@ class KeychainModule(reactContext: ReactApplicationContext) :
val storage = getSelectedStorage(options)
throwIfInsufficientLevel(storage, level)
val promptInfo = getPromptInfo(options)
val result = encryptToResult(alias, storage, username, password, level, promptInfo)
val result = mutex.withLock { encryptToResult(alias, storage, username, password, level, promptInfo) }
prefsStorage.storeEncryptedEntry(alias, result)
val results = Arguments.createMap()
results.putString(Maps.SERVICE, alias)
Expand Down Expand Up @@ -289,12 +294,12 @@ class KeychainModule(reactContext: ReactApplicationContext) :
} else {
getCipherStorageByName(storageName)
}
val decryptionResult = decryptCredentials(alias, cipher!!, resultSet, rules, promptInfo)
val decryptionResult = mutex.withLock { decryptCredentials(alias, cipher!!, resultSet, rules, promptInfo) }
val credentials = Arguments.createMap()
credentials.putString(Maps.SERVICE, alias)
credentials.putString(Maps.USERNAME, decryptionResult.username)
credentials.putString(Maps.PASSWORD, decryptionResult.password)
credentials.putString(Maps.STORAGE, cipher.getCipherStorageName())
credentials.putString(Maps.STORAGE, cipher?.getCipherStorageName())
promise.resolve(credentials)
} catch (e: KeyStoreAccessException) {
Log.e(KEYCHAIN_MODULE, e.message!!)
Expand Down Expand Up @@ -464,7 +469,7 @@ class KeychainModule(reactContext: ReactApplicationContext) :
private fun decryptCredentials(
alias: String,
current: CipherStorage,
resultSet: PrefsStorage.ResultSet,
resultSet: PrefsStorageBase.ResultSet,
@Rules rules: String,
promptInfo: PromptInfo
): DecryptionResult {
Expand Down Expand Up @@ -503,7 +508,7 @@ class KeychainModule(reactContext: ReactApplicationContext) :
private fun decryptToResult(
alias: String,
storage: CipherStorage,
resultSet: PrefsStorage.ResultSet,
resultSet: PrefsStorageBase.ResultSet,
promptInfo: PromptInfo
): DecryptionResult {
val handler = getInteractiveHandler(storage, promptInfo)
Expand Down Expand Up @@ -662,6 +667,7 @@ class KeychainModule(reactContext: ReactApplicationContext) :
SecurityLevel.ANY
}
}
// endregion

companion object {
// region Constants
Expand Down
125 changes: 0 additions & 125 deletions android/src/main/java/com/oblador/keychain/PrefsStorage.kt

This file was deleted.

Loading

0 comments on commit c9e2322

Please sign in to comment.