Fix: Prevent data loss when disabling encryption

Resolves a critical bug that caused permanent data loss of locked items when encryption was disabled.

The previous implementation deleted the encryption key without decrypting the data first. This commit introduces a safe-offboarding process:
- When disabling encryption, the user is now authenticated to retrieve the secret key.
- New `decryptAllLockedItems` functions in the ViewModels are called to decrypt all locked notes, recipes, and shopping lists across the app.
- Only after successful decryption is the key permanently deleted.

Additionally, this commit removes the last remnants of the old session-based unlocking logic and standardizes the auto-relock-on-exit behavior across all detail screens for a consistent user experience.
This commit is contained in:
2025-10-20 12:11:11 +02:00
parent 38290cc27f
commit 6068833eba
6 changed files with 188 additions and 175 deletions

View File

@@ -403,20 +403,45 @@ fun AppShell(
val onEncryptionToggle: (Boolean) -> Unit = { enabled ->
if (!enabled) { // Turning OFF
if (hasEncryptionPassword) {
// Has a password, so require auth to delete it
// Has a password, so require auth to decrypt all and delete the key
val activity = context.findActivity() as FragmentActivity
val cipher = keyManager.getDecryptionCipher()
if (cipher == null) {
// Fallback for safety, though this state should be unlikely if hasEncryptionPassword is true
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
}
}
val crypto = BiometricPrompt.CryptoObject(cipher!!)
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.remove_encryption),
subtitle = context.getString(R.string.confirm_to_disable_encryption),
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
onSuccess = {
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
// Decrypt all items before deleting the key
notesViewModel.decryptAllLockedItems(key, fileEncryptor)
shoppingListsViewModel.decryptAllLockedItems(key, fileEncryptor)
recipesViewModel.decryptAllLockedItems(key, fileEncryptor)
// Now, safely delete the key and disable the feature
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
}
}
}
},
onFailed = {},
@@ -456,18 +481,42 @@ fun AppShell(
val onRemoveEncryption: () -> Unit = {
val activity = context.findActivity() as FragmentActivity
val cipher = keyManager.getDecryptionCipher()
if (cipher == null) {
// This can happen if no key is set. In this case, just disable the feature.
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
}
}
val crypto = BiometricPrompt.CryptoObject(cipher!!)
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.remove_encryption),
subtitle = context.getString(R.string.confirm_to_remove_encryption),
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
onSuccess = {
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
// Decrypt all items in all ViewModels
notesViewModel.decryptAllLockedItems(key, fileEncryptor)
shoppingListsViewModel.decryptAllLockedItems(key, fileEncryptor)
recipesViewModel.decryptAllLockedItems(key, fileEncryptor)
// Now it's safe to delete the key
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
remove("biometric_unlock_enabled")
}
}
}
},
onFailed = {},
@@ -1311,7 +1360,6 @@ fun AppShell(
)
noteDetails.let { noteDetails ->
val isNoteLocked = noteDetails.isLocked
val isNoteSessionUnlocked = noteDetails.id in notesViewModel.unlockedNoteIds.collectAsState().value
DropdownMenuItem(
text = { Text(if (isNoteLocked) stringResource(R.string.unlock_note) else stringResource(R.string.lock_note)) },
@@ -1387,7 +1435,6 @@ fun AppShell(
)
recipeDetails.let { recipeDetails ->
val isRecipeLocked = recipeDetails.isLocked
val isRecipeSessionUnlocked = recipeDetails.id in recipesViewModel.unlockedRecipeIds.collectAsState().value
DropdownMenuItem(
text = { Text(if (isRecipeLocked) dynamicRecipeStrings.unlockItem else dynamicRecipeStrings.lockItem) },
@@ -1650,77 +1697,30 @@ fun AppShell(
NoteDetailScreen(
noteId = selectedNoteId,
viewModel = notesViewModel,
secretKey = secretKey,
fileEncryptor = fileEncryptor,
onUnlockClick = {
scope.launch {
if (secretKey != null) {
// Key already exists, decrypt directly
val noteToDecrypt = notesViewModel.noteDetails.value
try {
val decryptedContent = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(noteToDecrypt.content), secretKey!!), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt note content, assuming it's plaintext.")
noteToDecrypt.content
}
val decryptedTitle = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(noteToDecrypt.title), secretKey!!), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt note title, assuming it's plaintext.")
noteToDecrypt.title
}
notesViewModel.setDecryptedDetails(decryptedTitle, decryptedContent)
selectedNoteId?.let { notesViewModel.unlockNote(it) }
} catch (e: Exception) {
Log.e("MainActivity", "An unexpected error occurred during note decryption.", e)
}
} else {
// No session key, prompt for authentication
val cipher = keyManager.getDecryptionCipher()
if (cipher != null) {
val crypto = BiometricPrompt.CryptoObject(cipher)
val activity = context.findActivity() as FragmentActivity
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_note),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
val noteToDecrypt = notesViewModel.noteDetails.value
try {
val decryptedContent = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(noteToDecrypt.content), key), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt note content, assuming it's plaintext.")
noteToDecrypt.content // Fallback to plaintext
}
val decryptedTitle = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(noteToDecrypt.title), key), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt note title, assuming it's plaintext.")
noteToDecrypt.title // Fallback to plaintext
}
notesViewModel.setDecryptedDetails(decryptedTitle, decryptedContent)
selectedNoteId?.let { notesViewModel.unlockNote(it) }
} catch (e: Exception) {
Log.e("MainActivity", "An unexpected error occurred during note decryption.", e)
android.widget.Toast.makeText(context, "Decryption failed.", android.widget.Toast.LENGTH_LONG).show()
}
}
},
onFailed = {},
onError = { _, _ -> }
)
}
val activity = context as FragmentActivity
val cipher = keyManager.getDecryptionCipher()
if (cipher != null) {
val crypto = BiometricPrompt.CryptoObject(cipher)
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_note),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
selectedNoteId?.let { notesViewModel.toggleNoteLock(it, key, fileEncryptor) }
}
},
onFailed = {},
onError = { _, _ -> }
)
}
}
}
)
}
is Screen.Recipes -> {
@@ -1740,77 +1740,30 @@ fun AppShell(
de.lxtools.noteshop.ui.recipes.RecipeDetailScreen(
recipeId = selectedRecipeId,
viewModel = recipesViewModel,
secretKey = secretKey,
fileEncryptor = fileEncryptor,
onUnlockClick = {
scope.launch {
if (secretKey != null) {
// Key already exists, decrypt directly
val recipeToDecrypt = recipesViewModel.recipeDetails.value
try {
val decryptedContent = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(recipeToDecrypt.content), secretKey!!), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt recipe content, assuming it's plaintext.")
recipeToDecrypt.content
}
val decryptedTitle = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(recipeToDecrypt.title), secretKey!!), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt recipe title, assuming it's plaintext.")
recipeToDecrypt.title
}
recipesViewModel.setDecryptedDetails(decryptedTitle, decryptedContent)
selectedRecipeId?.let { recipesViewModel.unlockRecipe(it) }
} catch (e: Exception) {
Log.e("MainActivity", "An unexpected error occurred during recipe decryption.", e)
}
} else {
// No session key, prompt for authentication
val cipher = keyManager.getDecryptionCipher()
if (cipher != null) {
val crypto = BiometricPrompt.CryptoObject(cipher)
val activity = context.findActivity() as FragmentActivity
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_recipe),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
val recipeToDecrypt = recipesViewModel.recipeDetails.value
try {
val decryptedContent = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(recipeToDecrypt.content), key), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt recipe content, assuming it's plaintext.")
recipeToDecrypt.content // Fallback to plaintext
}
val decryptedTitle = try {
String(fileEncryptor.decrypt(Base64.getDecoder().decode(recipeToDecrypt.title), key), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt recipe title, assuming it's plaintext.")
recipeToDecrypt.title // Fallback to plaintext
}
recipesViewModel.setDecryptedDetails(decryptedTitle, decryptedContent)
selectedRecipeId?.let { recipesViewModel.unlockRecipe(it) }
} catch (e: Exception) {
Log.e("MainActivity", "An unexpected error occurred during recipe decryption.", e)
android.widget.Toast.makeText(context, "Decryption failed.", android.widget.Toast.LENGTH_LONG).show()
}
}
},
onFailed = {},
onError = { _, _ -> }
)
}
val activity = context as FragmentActivity
val cipher = keyManager.getDecryptionCipher()
if (cipher != null) {
val crypto = BiometricPrompt.CryptoObject(cipher)
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_recipe),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
selectedRecipeId?.let { recipesViewModel.toggleRecipeLock(it, key, fileEncryptor) }
}
},
onFailed = {},
onError = { _, _ -> }
)
}
}
}
)
}
is Screen.About -> {

View File

@@ -29,17 +29,42 @@ import androidx.biometric.BiometricManager
import androidx.compose.runtime.remember
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import de.lxtools.noteshop.security.FileEncryptor
import javax.crypto.SecretKey
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteDetailScreen(
noteId: Int?,
viewModel: NotesViewModel,
modifier: Modifier = Modifier,
onUnlockClick: () -> Unit
onUnlockClick: () -> Unit,
secretKey: SecretKey?,
fileEncryptor: FileEncryptor
) {
val noteDetails by viewModel.noteDetails.collectAsState()
val unlockedNoteIds by viewModel.unlockedNoteIds.collectAsState()
val scope = rememberCoroutineScope()
var wasLockedInitially by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(noteDetails) {
if (wasLockedInitially == null) { // This ensures we only set it once
wasLockedInitially = noteDetails.isLocked
}
}
DisposableEffect(noteId) {
onDispose {
if (wasLockedInitially == true) {
viewModel.toggleNoteLock(noteId ?: 0, secretKey, fileEncryptor)
}
}
}
LaunchedEffect(key1 = noteId) {
if (noteId != null) {
@@ -50,7 +75,7 @@ fun NoteDetailScreen(
}
}
if (noteDetails.isLocked && noteDetails.id !in unlockedNoteIds) {
if (noteDetails.isLocked) {
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
} else {
Column(

View File

@@ -38,13 +38,6 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
_searchQuery.value = query
}
private val _unlockedNoteIds = MutableStateFlow<Set<Int>>(emptySet())
val unlockedNoteIds: StateFlow<Set<Int>> = _unlockedNoteIds.asStateFlow()
fun unlockNote(noteId: Int) {
_unlockedNoteIds.value = _unlockedNoteIds.value + noteId
}
fun toggleNoteLock(noteId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val note = noteshopRepository.getNoteStream(noteId).firstOrNull()
@@ -60,7 +53,6 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
noteToUpdate = noteToUpdate.copy(
content = encryptedContent
)
_unlockedNoteIds.value = _unlockedNoteIds.value - noteId
} catch (e: Exception) {
Log.e("NotesViewModel", "Failed to encrypt note content on lock.", e)
return@launch // Don't update if encryption fails
@@ -248,6 +240,15 @@ class NotesViewModel(private val noteshopRepository: NoteshopRepository) : ViewM
}
}
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
val allNotes = noteshopRepository.getAllNotesStream().firstOrNull()
allNotes?.forEach { note ->
if (note.isLocked) {
toggleNoteLock(note.id, secretKey, fileEncryptor)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}

View File

@@ -38,18 +38,42 @@ import de.lxtools.noteshop.R
import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import de.lxtools.noteshop.security.FileEncryptor
import javax.crypto.SecretKey
import androidx.compose.runtime.DisposableEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecipeDetailScreen(
recipeId: Int?,
viewModel: RecipesViewModel,
modifier: Modifier = Modifier,
onUnlockClick: () -> Unit
onUnlockClick: () -> Unit,
secretKey: SecretKey?,
fileEncryptor: FileEncryptor
) {
val recipeDetails by viewModel.recipeDetails.collectAsState()
val unlockedRecipeIds by viewModel.unlockedRecipeIds.collectAsState()
var isEditMode by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var wasLockedInitially by remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(recipeDetails) {
if (wasLockedInitially == null) { // This ensures we only set it once
wasLockedInitially = recipeDetails.isLocked
}
}
DisposableEffect(recipeId) {
onDispose {
if (wasLockedInitially == true) {
viewModel.toggleRecipeLock(recipeId ?: 0, secretKey, fileEncryptor)
}
}
}
LaunchedEffect(key1 = recipeId) {
if (recipeId != null) {
@@ -60,7 +84,7 @@ fun RecipeDetailScreen(
}
}
if (recipeDetails.isLocked && recipeDetails.id !in unlockedRecipeIds) {
if (recipeDetails.isLocked) {
LockedScreenPlaceholder(onUnlockClick = onUnlockClick)
} else {
Column(

View File

@@ -38,13 +38,6 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
_searchQuery.value = query
}
private val _unlockedRecipeIds = MutableStateFlow<Set<Int>>(emptySet())
val unlockedRecipeIds: StateFlow<Set<Int>> = _unlockedRecipeIds.asStateFlow()
fun unlockRecipe(recipeId: Int) {
_unlockedRecipeIds.value = _unlockedRecipeIds.value + recipeId
}
fun toggleRecipeLock(recipeId: Int, secretKey: SecretKey?, fileEncryptor: FileEncryptor) {
viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
val recipe = noteshopRepository.getRecipeStream(recipeId).firstOrNull()
@@ -60,7 +53,6 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
recipeToUpdate = recipeToUpdate.copy(
content = encryptedContent
)
_unlockedRecipeIds.value = _unlockedRecipeIds.value - recipeId
} catch (e: Exception) {
Log.e("RecipesViewModel", "Failed to encrypt recipe content on lock.", e)
return@launch // Don't update if encryption fails
@@ -251,6 +243,15 @@ class RecipesViewModel(private val noteshopRepository: NoteshopRepository) : Vie
}
}
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
val allRecipes = noteshopRepository.getAllRecipesStream().firstOrNull()
allRecipes?.forEach { recipe ->
if (recipe.isLocked) {
toggleRecipeLock(recipe.id, secretKey, fileEncryptor)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}

View File

@@ -420,6 +420,15 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
}
}
suspend fun decryptAllLockedItems(secretKey: SecretKey, fileEncryptor: FileEncryptor) {
val allLists = noteshopRepository.getAllShoppingListsWithItemsStream().firstOrNull()
allLists?.forEach { listWithItems ->
if (listWithItems.shoppingList.isLocked) {
toggleListLock(listWithItems.shoppingList.id, secretKey, fileEncryptor)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}