Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,6 @@ dependencies {

// draggable list
implementation(libs.reorderable)

implementation(libs.androidx.security.crypto)
}
5 changes: 4 additions & 1 deletion app/src/main/java/com/flux/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.flux.other.createNotificationChannel
import com.flux.ui.effects.ScreenEffect
import com.flux.ui.state.States
import com.flux.ui.theme.FluxTheme
import com.flux.ui.viewModel.BackupSettingsViewModel
import com.flux.ui.viewModel.BackupViewModel
import com.flux.ui.viewModel.EventViewModel
import com.flux.ui.viewModel.HabitViewModel
Expand Down Expand Up @@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
val backupViewModel: BackupViewModel = hiltViewModel()
val labelViewModel: LabelViewModel = hiltViewModel()
val progressBoardViewModel: ProgressBoardViewModel = hiltViewModel()
val backupSettingsViewModel: BackupSettingsViewModel = hiltViewModel()

// States
val settings by settingsViewModel.state.collectAsState()
Expand Down Expand Up @@ -114,7 +116,8 @@ class MainActivity : AppCompatActivity() {
settingsViewModel,
backupViewModel,
labelViewModel,
progressBoardViewModel
progressBoardViewModel,
backupSettingsViewModel
),
states = States(
notesState,
Expand Down
16 changes: 3 additions & 13 deletions app/src/main/java/com/flux/data/database/FluxDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -411,18 +411,12 @@ val MIGRATION_10_11 = object : Migration(10, 11) {
}

val MIGRATION_11_12 = object : Migration(11, 12) {

private val TAG = "Migration_11_12"
private val OLD_TABLE = "WorkspaceModel"
private val TMP_TABLE = "WorkspaceModel_new"

override fun migrate(db: SupportSQLiteDatabase) {
Log.i(TAG, "Starting migration 11 -> 12 (passKey -> hashed passKeyHash)")

recreateTableWithHashedColumn(db)
rehashExistingPasswords(db)

Log.i(TAG, "Migration 11 -> 12 finished")
}

/** Step 1: recreate WorkspaceModel with passKeyHash instead of passKey, copying all rows as-is. */
Expand Down Expand Up @@ -455,8 +449,6 @@ val MIGRATION_11_12 = object : Migration(11, 12) {

db.safeExec("DROP TABLE IF EXISTS `$OLD_TABLE`")
db.safeExec("ALTER TABLE `$TMP_TABLE` RENAME TO `$OLD_TABLE`")

Log.d(TAG, "WorkspaceModel table recreated with passKeyHash column")
}

/**
Expand Down Expand Up @@ -487,7 +479,6 @@ val MIGRATION_11_12 = object : Migration(11, 12) {

try {
if (PasswordHasher.isHashed(currentValue)) {
Log.d(TAG, "Workspace $workspaceId already hashed, skipping")
alreadyHashed++
continue
}
Expand All @@ -501,22 +492,21 @@ val MIGRATION_11_12 = object : Migration(11, 12) {
arrayOf(hashed, workspaceId)
)
migrated++
Log.d(TAG, "Workspace $workspaceId passkey hashed successfully")
} catch (rowError: Exception) {
failed++
Log.e(
TAG,
"Migration_11_12",
"Failed to hash passkey for workspace $workspaceId, leaving value untouched",
rowError
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Critical error while iterating WorkspaceModel rows for hashing", e)
Log.e("Migration_11_12", "Critical error while iterating WorkspaceModel rows for hashing", e)
} finally {
cursor?.close()
Log.i(
TAG,
"Migration_11_12",
"Passkey hashing summary — total: $total, migrated: $migrated, " +
"alreadyHashed: $alreadyHashed, failed: $failed"
)
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/com/flux/di/BackupCryptoModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.flux.di

import com.flux.other.crypto.AesGcmBackupEncryptor
import com.flux.other.crypto.BackupCredentialsStore
import com.flux.other.crypto.BackupEncryptor
import com.flux.other.crypto.EncryptedPrefsBackupCredentialsStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class BackupCryptoModule {

@Binds
@Singleton
abstract fun bindBackupCredentialsStore(
impl: EncryptedPrefsBackupCredentialsStore
): BackupCredentialsStore

companion object {
@Provides
@Singleton
fun provideBackupEncryptor(): BackupEncryptor = AesGcmBackupEncryptor()
}
}
2 changes: 1 addition & 1 deletion app/src/main/java/com/flux/navigation/NavRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ val SettingsScreens =
Contact(navController, states.settings.data.cornerRadius)
},
NavRoutes.Backup.route to { navController, snackbarHostState, states, viewModels ->
Data(navController, states.settings.data.cornerRadius, states.settings, snackbarHostState, viewModels.backupViewModel, viewModels.settingsViewModel::onEvent)
Data(navController, states.settings.data.cornerRadius, states.settings, snackbarHostState, viewModels.backupViewModel, viewModels.backupSettingsViewModel, viewModels.settingsViewModel::onEvent)
},
NavRoutes.Editor.route to { navController, _, states, viewModels ->
Editor(navController, states.settings, viewModels.settingsViewModel::onEvent)
Expand Down
66 changes: 66 additions & 0 deletions app/src/main/java/com/flux/other/crypto/BackupCredentialStore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.flux.other.crypto

import android.content.Context
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

interface BackupCredentialsStore {
suspend fun getPassword(): CharArray?
suspend fun setPassword(password: CharArray?)
fun hasPasswordFlow(): StateFlow<Boolean>
}

/**
* The ONE place the backup password lives. Deliberately outside Room and outside
* FluxBackup/SettingsModel — it must never be written into an exported backup file.
* Backed by Android Keystore via EncryptedSharedPreferences: protects against file
* extraction / offline attacks, not against a fully unlocked device in an attacker's hand.
* Lost on uninstall — by design, there is no recovery path.
*/

@Singleton
class EncryptedPrefsBackupCredentialsStore @Inject constructor(
@param:ApplicationContext private val context: Context
) : BackupCredentialsStore {

private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()

private val prefs = EncryptedSharedPreferences.create(
context,
"flux_backup_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

private val _hasPassword = MutableStateFlow(prefs.contains(KEY_PASSWORD))
override fun hasPasswordFlow(): StateFlow<Boolean> = _hasPassword.asStateFlow()

override suspend fun getPassword(): CharArray? = withContext(Dispatchers.IO) {
prefs.getString(KEY_PASSWORD, null)?.toCharArray()
}

override suspend fun setPassword(password: CharArray?) = withContext(Dispatchers.IO) {
if (password == null) {
prefs.edit { remove(KEY_PASSWORD) }
} else {
prefs.edit { putString(KEY_PASSWORD, String(password)) }
}
_hasPassword.value = (password != null)
}

private companion object {
const val KEY_PASSWORD = "backup_password"
}
}
21 changes: 21 additions & 0 deletions app/src/main/java/com/flux/other/crypto/BackupCryptoException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.flux.other.crypto

import com.flux.R
import androidx.annotation.StringRes

sealed class BackupCryptoException(
@param:StringRes val messageRes: Int,
vararg val formatArgs: Any,
cause: Throwable? = null
) : Exception(null, cause) {

class WrongPasswordOrCorrupted(cause: Throwable) : BackupCryptoException(
R.string.backup_wrong_password_or_corrupted,
cause = cause
)

class UnsupportedVersion(version: Int) : BackupCryptoException(
R.string.unsupported_backup_version,
version
)
}
83 changes: 83 additions & 0 deletions app/src/main/java/com/flux/other/crypto/BackupEncryptor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.flux.other.crypto

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.ByteBuffer
import java.security.SecureRandom
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec

interface BackupEncryptor {
suspend fun encrypt(plaintext: ByteArray, password: CharArray): ByteArray
suspend fun decrypt(encryptedFile: ByteArray, password: CharArray): ByteArray
}

/**
* AES-256-GCM with a PBKDF2-derived key. Salt + IV are random per export and stored
* in the file header, so the file is fully self-contained and portable across devices —
* only the password (known to the user) is needed to open it anywhere.
*/
class AesGcmBackupEncryptor : BackupEncryptor {

private companion object {
const val SALT_SIZE = 16
const val IV_SIZE = 12
const val GCM_TAG_BITS = 128
const val PBKDF2_ITERATIONS = 210_000 // OWASP-recommended floor for PBKDF2-HMAC-SHA256
const val KEY_BITS = 256
const val TRANSFORMATION = "AES/GCM/NoPadding"
}

override suspend fun encrypt(plaintext: ByteArray, password: CharArray): ByteArray =
withContext(Dispatchers.Default) {
val salt = randomBytes(SALT_SIZE)
val iv = randomBytes(IV_SIZE)
val key = deriveKey(password, salt)

val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
}
val ciphertext = cipher.doFinal(plaintext)

BackupFileFormat.MAGIC + byteArrayOf(BackupFileFormat.VERSION) + salt + iv + ciphertext
}

override suspend fun decrypt(encryptedFile: ByteArray, password: CharArray): ByteArray =
withContext(Dispatchers.Default) {
val buffer = ByteBuffer.wrap(encryptedFile).apply { position(BackupFileFormat.MAGIC.size) }

val version = buffer.get()
if (version != BackupFileFormat.VERSION) {
throw BackupCryptoException.UnsupportedVersion(version.toInt())
}

val salt = ByteArray(SALT_SIZE).also { buffer.get(it) }
val iv = ByteArray(IV_SIZE).also { buffer.get(it) }
val ciphertext = ByteArray(buffer.remaining()).also { buffer.get(it) }

val key = deriveKey(password, salt)
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
}

try {
cipher.doFinal(ciphertext)
} catch (e: AEADBadTagException) {
throw BackupCryptoException.WrongPasswordOrCorrupted(e)
}
}

private fun deriveKey(password: CharArray, salt: ByteArray): SecretKey {
val spec = PBEKeySpec(password, salt, PBKDF2_ITERATIONS, KEY_BITS)
val raw = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
spec.clearPassword()
return SecretKeySpec(raw, "AES")
}

private fun randomBytes(size: Int): ByteArray = ByteArray(size).also { SecureRandom().nextBytes(it) }
}
13 changes: 13 additions & 0 deletions app/src/main/java/com/flux/other/crypto/BackupFileFormat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.flux.other.crypto

/**
* Self-describing header so encrypted and legacy plaintext backups can coexist
* in the same folder, and imports can tell them apart without relying on file extension.
*/
object BackupFileFormat {
val MAGIC: ByteArray = "FLUXBK1".toByteArray(Charsets.US_ASCII) // 7 bytes
const val VERSION: Byte = 1

fun isEncrypted(bytes: ByteArray): Boolean =
bytes.size >= MAGIC.size && bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)
}
Loading
Loading