diff --git a/android/uhabits-android/build.gradle b/android/uhabits-android/build.gradle index 2ac303436..6d1d802de 100644 --- a/android/uhabits-android/build.gradle +++ b/android/uhabits-android/build.gradle @@ -100,6 +100,7 @@ dependencies { implementation "io.ktor:ktor-client-android:$KTOR_VERSION" implementation "io.ktor:ktor-client-json:$KTOR_VERSION" implementation "io.ktor:ktor-client-jackson:$KTOR_VERSION" + implementation "com.google.guava:guava:30.0-android" implementation 'androidx.constraintlayout:constraintlayout:1.1.3' compileOnly "javax.annotation:jsr250-api:1.0" @@ -118,7 +119,6 @@ dependencies { androidTestImplementation 'androidx.annotation:annotation:1.0.0' androidTestImplementation 'androidx.test:rules:1.1.1' androidTestImplementation 'androidx.test.ext:junit:1.1.1' - androidTestImplementation "com.google.guava:guava:24.1-android" androidTestImplementation "io.ktor:ktor-client-mock:$KTOR_VERSION" androidTestImplementation "io.ktor:ktor-jackson:$KTOR_VERSION" androidTestImplementation project(":uhabits-core") diff --git a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt index 258022b24..48f37d75f 100644 --- a/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt +++ b/android/uhabits-android/src/androidTest/java/org/isoron/uhabits/utils/EncryptionExtTest.kt @@ -23,15 +23,6 @@ import org.junit.* import java.io.* class EncryptionExtTest : BaseAndroidTest() { - @Test - fun test_encrypt_decrypt_string() { - val original = "Hello world!" - val key = generateEncryptionKey() - val encrypted = original.encrypt(key) - val decrypted = encrypted.decrypt(key) - assertEquals("Hello world!", decrypted) - } - @Test fun test_encrypt_decrypt_file() { val original = File.createTempFile("file", ".txt") @@ -40,7 +31,7 @@ class EncryptionExtTest : BaseAndroidTest() { writer.println("encryption test") writer.close() - val key = generateEncryptionKey() + val key = EncryptionKey.generate() val encrypted = original.encryptToString(key) val decrypted = File.createTempFile("file", ".txt") diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java index 5847f2395..2b7f00070 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/settings/SettingsFragment.java @@ -128,6 +128,18 @@ public class SettingsFragment extends PreferenceFragmentCompat startActivity(intent); return true; } + else if (key.equals("pref_sync_enabled_dummy")) + { + if (prefs.isSyncEnabled()) + { + prefs.disableSync(); + } + else + { + Context context = getActivity(); + context.startActivity(new IntentFactory().startSyncActivity(context)); + } + } return super.onPreferenceTreeClick(preference); } @@ -159,6 +171,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private void updateSyncPreferences() { findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled()); + ((CheckBoxPreference) findPreference("pref_sync_enabled_dummy")).setChecked(prefs.isSyncEnabled()); } private void updateWeekdayPreference() @@ -182,19 +195,7 @@ public class SettingsFragment extends PreferenceFragmentCompat Log.d("SettingsFragment", "updating widgets"); widgetUpdater.updateWidgets(); } - if (key.equals("pref_sync_enabled")) - { - if (prefs.isSyncEnabled()) - { - Context context = getActivity(); - context.startActivity(new IntentFactory().startSyncActivity(context)); - } - else - { - prefs.setEncryptionKey(""); - prefs.setSyncKey(""); - } - } + BackupManager.dataChanged("org.isoron.uhabits"); updateWeekdayPreference(); updateSyncPreferences(); diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt index 3a86bae06..f09dd98b8 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/activities/sync/SyncActivity.kt @@ -93,7 +93,7 @@ class SyncActivity : BaseActivity() { private fun register() { displayLoading() taskRunner.execute(object : Task { - private lateinit var encKey: String + private lateinit var encKey: EncryptionKey private lateinit var syncKey: String private var error = false override fun doInBackground() { @@ -101,11 +101,8 @@ class SyncActivity : BaseActivity() { val server = RemoteSyncServer(baseURL = preferences.syncBaseURL) try { syncKey = server.register() - encKey = generateEncryptionKey() - preferences.isSyncEnabled = true - preferences.encryptionKey = encKey - preferences.syncKey = syncKey; - syncManager.sync() + encKey = EncryptionKey.generate() + preferences.enableSync(syncKey, encKey.base64) } catch (e: ServiceUnavailable) { error = true } diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt index e28a642df..de545a73f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/sync/SyncManager.kt @@ -46,6 +46,9 @@ class SyncManager @Inject constructor( private var currVersion = 0L private var dirty = true + private lateinit var encryptionKey: EncryptionKey + private lateinit var syncKey: String + init { preferences.addListener(this) commandRunner.addListener(this) @@ -53,44 +56,48 @@ class SyncManager @Inject constructor( suspend fun sync() { if (!preferences.isSyncEnabled) { - Log.i("SyncManager", "Device sync is disabled. Skipping sync") + Log.i("SyncManager", "Device sync is disabled. Skipping sync.") return } + encryptionKey = EncryptionKey.fromBase64(preferences.encryptionKey) + syncKey = preferences.syncKey try { - Log.i("SyncManager", "Starting sync (key: ${preferences.syncKey})") - fetchAndMerge() - upload() + Log.i("SyncManager", "Starting sync (key: ${encryptionKey.base64})") + pull() + push() Log.i("SyncManager", "Sync finished") } catch (e: Exception) { Log.e("SyncManager", "Unexpected sync exception. Disabling sync", e) - preferences.isSyncEnabled = false - preferences.syncKey = "" - preferences.encryptionKey = "" + preferences.disableSync() } } - suspend fun upload() { + private suspend fun push() { if (!dirty) { Log.i("SyncManager", "Database not dirty. Skipping upload.") return } Log.i("SyncManager", "Encrypting database...") val db = DatabaseUtils.getDatabaseFile(context) - val encryptedDB = db.encryptToString(preferences.encryptionKey) + val encryptedDB = db.encryptToString(encryptionKey) Log.i("SyncManager", "Uploading database (version ${currVersion}, ${encryptedDB.length / 1024} KB)") server.put(preferences.syncKey, SyncData(currVersion, encryptedDB)) dirty = false } - suspend fun fetchAndMerge() { + private suspend fun pull() { Log.i("SyncManager", "Fetching database from server...") val data = server.getData(preferences.syncKey) Log.i("SyncManager", "Fetched database (version ${data.version}, ${data.content.length / 1024} KB)") + if (data.version == 0L) { + Log.i("SyncManager", "Initial upload detected. Marking db as dirty.") + dirty = true + } if (data.version <= currVersion) { Log.i("SyncManager", "Local version is up-to-date. Skipping merge.") } else { Log.i("SyncManager", "Decrypting and merging with local changes...") - data.content.decryptToFile(preferences.encryptionKey, tmpFile) + data.content.decryptToFile(encryptionKey, tmpFile) taskRunner.execute(importDataTaskFactory.create(tmpFile) { tmpFile.delete() }) } currVersion = data.version + 1 diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt index eb72eac19..e77267ce4 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/utils/EncryptionExt.kt @@ -17,19 +17,61 @@ * with this program. If not, see . */ +@file:Suppress("UnstableApiUsage") + package org.isoron.uhabits.utils import android.util.* +import com.google.common.io.* import java.io.* import java.nio.* -import java.nio.charset.StandardCharsets.* +import java.util.zip.* import javax.crypto.* import javax.crypto.spec.* -fun ByteArray.encrypt(key: String): ByteArray { - val keySpec = SecretKeySpec(Base64.decode(key, Base64.DEFAULT), "AES") +/** + * Encryption key which can be used with [File.encryptToString], [String.decryptToFile], + * [ByteArray.encrypt] and [ByteArray.decrypt]. + * + * To randomly generate a new key, use [EncryptionKey.generate]. To load a key from a + * Base64-encoded string, use [EncryptionKey.fromBase64]. + */ +class EncryptionKey private constructor( + val base64: String, + val secretKey: SecretKey, +) { + companion object { + + fun fromBase64(base64: String): EncryptionKey { + val keySpec = SecretKeySpec(Base64.decode(base64, Base64.DEFAULT), "AES") + return EncryptionKey(base64, keySpec) + } + + private fun fromSecretKey(spec: SecretKey): EncryptionKey { + val base64 = Base64.encodeToString(spec.encoded, Base64.DEFAULT).trim() + return EncryptionKey(base64, spec) + } + + fun generate(): EncryptionKey { + try { + val generator = KeyGenerator.getInstance("AES").apply { init(256) } + return fromSecretKey(generator.generateKey()) + } catch (e: Exception) { + throw RuntimeException(e) + } + } + } +} + +/** + * Encrypts the byte stream using the provided symmetric encryption key. + * + * The initialization vector (16 bytes) is prepended to the cipher text. To decrypt the result, use + * [ByteArray.decrypt], providing the same key. + */ +fun ByteArray.encrypt(key: EncryptionKey): ByteArray { val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING") - cipher.init(Cipher.ENCRYPT_MODE, keySpec) + cipher.init(Cipher.ENCRYPT_MODE, key.secretKey) val encrypted = cipher.doFinal(this) return ByteBuffer .allocate(16 + encrypted.size) @@ -38,48 +80,50 @@ fun ByteArray.encrypt(key: String): ByteArray { .array() } -fun ByteArray.decrypt(key: String): ByteArray { +/** + * Decrypts a byte stream generated by [ByteArray.encrypt]. + */ +fun ByteArray.decrypt(key: EncryptionKey): ByteArray { val buffer = ByteBuffer.wrap(this) val iv = ByteArray(16) buffer.get(iv) val encrypted = ByteArray(buffer.remaining()) buffer.get(encrypted) - val keySpec = SecretKeySpec(Base64.decode(key, Base64.DEFAULT), "AES") val cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING") - cipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(iv)) + cipher.init(Cipher.DECRYPT_MODE, key.secretKey, IvParameterSpec(iv)) return cipher.doFinal(encrypted) } -fun String.encrypt(key: String): String { - return Base64.encodeToString(this.toByteArray().encrypt(key), Base64.DEFAULT) -} - -fun String.decrypt(key: String): String { - return String(Base64.decode(this, Base64.DEFAULT).decrypt(key), UTF_8) -} - -fun String.decryptToFile(key: String, output: File) -{ - val outputStream = FileOutputStream(output) - output.writeBytes(Base64.decode(this, Base64.DEFAULT).decrypt(key)) - outputStream.close() +/** + * Takes a string produced by [File.encryptToString], decodes it with Base64, decompresses it with + * gzip, decrypts it with the provided key, then writes the output to the specified file. + */ +fun String.decryptToFile(key: EncryptionKey, output: File) { + val bytes = Base64.decode(this, Base64.DEFAULT).decrypt(key) + ByteArrayInputStream(bytes).use { bytesInputStream -> + GZIPInputStream(bytesInputStream).use { gzipInputStream -> + FileOutputStream(output).use { fileOutputStream -> + ByteStreams.copy(gzipInputStream, fileOutputStream) + } + } + } } -fun File.encryptToString(key: String): String { - val bytes = ByteArray(this.length().toInt()) - val inputStream = FileInputStream(this) - inputStream.read(bytes) - inputStream.close() - return Base64.encodeToString(bytes.encrypt(key), Base64.DEFAULT) +/** + * Compresses the file with gzip, encrypts it using the the provided key, then returns a string + * containing the Base64-encoded cipher bytes. + * + * To decrypt and decompress the cipher text back into a file, use [String.decryptToFile]. + */ +fun File.encryptToString(key: EncryptionKey): String { + ByteArrayOutputStream().use { bytesOutputStream -> + FileInputStream(this).use { inputStream -> + GZIPOutputStream(bytesOutputStream).use { gzipOutputStream -> + ByteStreams.copy(inputStream, gzipOutputStream) + val bytes = bytesOutputStream.toByteArray() + return Base64.encodeToString(bytes.encrypt(key), Base64.DEFAULT) + } + } + } } -fun generateEncryptionKey(): String { - return try { - val keygen = KeyGenerator.getInstance("AES") - keygen.init(256) - val key = keygen.generateKey() - Base64.encodeToString(key.encoded, Base64.DEFAULT).trim() - } catch (e: Exception) { - throw RuntimeException(e) - } -} \ No newline at end of file diff --git a/android/uhabits-android/src/main/res/xml/preferences.xml b/android/uhabits-android/src/main/res/xml/preferences.xml index 9c10105c3..0f1e2d654 100644 --- a/android/uhabits-android/src/main/res/xml/preferences.xml +++ b/android/uhabits-android/src/main/res/xml/preferences.xml @@ -123,7 +123,7 @@ diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java index dd36ae983..3f3688e06 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/preferences/Preferences.java @@ -319,30 +319,29 @@ public class Preferences return storage.getString("pref_sync_key", ""); } - public void setSyncKey(String key) - { - storage.putString("pref_sync_key", key); - } - public String getEncryptionKey() { return storage.getString("pref_encryption_key", ""); } - public void setEncryptionKey(String key) + public boolean isSyncEnabled() { - storage.putString("pref_encryption_key", key); + return storage.getBoolean("pref_sync_enabled", false); } - public boolean isSyncEnabled() + public void enableSync(String syncKey, String encKey) { - return storage.getBoolean("pref_sync_enabled", false); + storage.putBoolean("pref_sync_enabled", true); + storage.putString("pref_sync_key", syncKey); + storage.putString("pref_encryption_key", encKey); + for (Listener l : listeners) l.onSyncEnabled(); } - public void setSyncEnabled(boolean enabled) + public void disableSync() { - storage.putBoolean("pref_sync_enabled", enabled); - if(enabled) for (Listener l : listeners) l.onSyncEnabled(); + storage.putBoolean("pref_sync_enabled", false); + storage.putString("pref_sync_key", ""); + storage.putString("pref_encryption_key", ""); } public boolean areQuestionMarksEnabled() diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java index fb52b006f..719f9d895 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.java @@ -165,9 +165,7 @@ public class ListHabitsBehavior return; } screen.showConfirmInstallSyncKey(() -> { - prefs.setSyncKey(syncKey); - prefs.setEncryptionKey(encryptionKey); - prefs.setSyncEnabled(true); + prefs.enableSync(syncKey, encryptionKey); screen.showMessage(Message.SYNC_ENABLED); }); }