diff --git a/ORLib/src/main/java/io/openremote/orlib/service/SecureStorageProvider.kt b/ORLib/src/main/java/io/openremote/orlib/service/SecureStorageProvider.kt new file mode 100644 index 0000000..cab5c64 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/SecureStorageProvider.kt @@ -0,0 +1,138 @@ +package io.openremote.orlib.service + +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.preference.PreferenceManager +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SecureStorageProvider(val context: Context) { + + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE) + private val keyAlias = context.packageName + ".secure_storage_key" + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = + "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}" + } + + init { + if (!isKeyExists()) { + generateKey() + + // Migrate data from default shared preferences to secure storage + // This is a one-time operation + val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + val allEntries = defaultSharedPreferences.all + for ((key, value) in allEntries) { + val encryptedData = encryptData(value as String) + storeData(key, encryptedData) + defaultSharedPreferences.edit().remove(key).apply() + } + } + } + + private fun getKeyStore(): KeyStore { + return KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + } + + private fun generateKey() { + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + + private fun isKeyExists(): Boolean { + val keyStore = getKeyStore() + return keyStore.containsAlias(keyAlias) + } + + private fun getSecretKey(): SecretKey { + val keyStore = getKeyStore() + return keyStore.getKey(keyAlias, null) as SecretKey + } + + private fun encryptData(data: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + val iv = cipher.iv + val encryptedData = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + val combined = iv + encryptedData + return Base64.encodeToString(combined, Base64.DEFAULT) + } + + fun initialize(): Map { + return hashMapOf( + "action" to "PROVIDER_INIT", + "provider" to "storage", + "version" to "1.0.0", + "enabled" to true, + "requiresPermission" to false, + "hasPermission" to true, + "success" to true, + ) + } + + fun enable(): Map { + return hashMapOf( + "action" to "PROVIDER_ENABLE", + "provider" to "storage", + "hasPermission" to true, + "success" to true, + ) + } + + fun storeData(key: String?, data: String?) { + if (key == null) return + + val editor = sharedPreferences.edit() + if (data == null) { + editor.remove(key) + } else { + val encryptedData = encryptData(data) + editor.putString(key, encryptedData) + } + editor.apply() + } + + fun retrieveData(key: String?): Map { + val result = hashMapOf( + "action" to "RETRIEVE", + "provider" to "storage", + "key" to key, + "value" to null + ) + + val encryptedData = sharedPreferences.getString(key, null) ?: return result + + val combined = Base64.decode(encryptedData, Base64.DEFAULT) + val iv = combined.copyOfRange(0, 12) + val data = combined.copyOfRange(12, combined.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + val plainData = cipher.doFinal(data).toString(Charsets.UTF_8) + + result["value"] = plainData + return result + } +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt b/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt index 57549ae..214b517 100644 --- a/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt +++ b/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt @@ -37,6 +37,7 @@ import io.openremote.orlib.service.BleProvider import io.openremote.orlib.service.ConnectivityChangeReceiver import io.openremote.orlib.service.GeofenceProvider import io.openremote.orlib.service.QrScannerProvider +import io.openremote.orlib.service.SecureStorageProvider import io.openremote.orlib.shared.SharedData.offlineActivity import org.json.JSONException import org.json.JSONObject @@ -72,6 +73,7 @@ open class OrMainActivity : Activity() { private var geofenceProvider: GeofenceProvider? = null private var qrScannerProvider: QrScannerProvider? = null private var bleProvider: BleProvider? = null + private var secureStorageProvider: SecureStorageProvider? = null private var consoleId: String? = null private var connectFailCount: Int = 0 private var connectFailResetHandler: Handler? = null @@ -797,26 +799,18 @@ open class OrMainActivity : Activity() { @Throws(JSONException::class) private fun handleStorageProviderMessage(data: JSONObject) { val action = data.getString("action") + if (secureStorageProvider == null) { + secureStorageProvider = SecureStorageProvider(activity) + } when { action.equals("PROVIDER_INIT", ignoreCase = true) -> { - val response: MutableMap = HashMap() - response["action"] = "PROVIDER_INIT" - response["provider"] = "storage" - response["version"] = "1.0.0" - response["enabled"] = true - response["requiresPermission"] = false - response["hasPermission"] = true - response["success"] = true + val response = secureStorageProvider!!.initialize() notifyClient(response) } action.equals("PROVIDER_ENABLE", ignoreCase = true) -> { // Doesn't require enabling but just in case it gets called lets return a valid response - val response: MutableMap = HashMap() - response["action"] = "PROVIDER_ENABLE" - response["provider"] = "storage" - response["hasPermission"] = true - response["success"] = true + val response = secureStorageProvider!!.enable() notifyClient(response) } @@ -824,7 +818,7 @@ open class OrMainActivity : Activity() { try { val key = data.getString("key") val valueJson = data.getString("value") - storeData(key, valueJson) + secureStorageProvider!!.storeData(key, valueJson) } catch (e: JSONException) { LOG.log(Level.SEVERE, "Failed to store data", e) } @@ -833,12 +827,7 @@ open class OrMainActivity : Activity() { action.equals("RETRIEVE", ignoreCase = true) -> { try { val key = data.getString("key") - val dataJson = retrieveData(key) - val response: MutableMap = HashMap() - response["action"] = "RETRIEVE" - response["provider"] = "storage" - response["key"] = key - response["value"] = dataJson + val response = secureStorageProvider!!.retrieveData(key) notifyClient(response) } catch (e: JSONException) { LOG.log(Level.SEVERE, "Failed to retrieve data", e) @@ -969,26 +958,6 @@ open class OrMainActivity : Activity() { } } - private fun storeData(key: String?, data: String?) { - val editor = sharedPreferences.edit() - if (data == null) { - editor.remove(key) - } else { - editor.putString(key, data) - } - editor.apply() - } - - private fun retrieveData(key: String?): Any? { - val str = sharedPreferences.getString(key, null) ?: return null - // Parse data JSON - return try { - mapper.readTree(str) - } catch (e: JsonProcessingException) { - str - } - } - private fun onConnectivityChanged(connectivity: Boolean) { LOG.info("Connectivity changed: $connectivity") if (connectivity && !webViewIsLoading && !lastConnectivity) {