feat(security, settings): Überarbeitung der Verschlüsselungs-Sicherheit und -Logik

Dieses Commit behebt kritische Sicherheitslücken und verbessert die Logik sowie die Benutzerfreundlichkeit der Verschlüsselungsfunktionen.

WICHTIGSTE ÄNDERUNGEN:

1.  **Sicherheitslücken geschlossen:**
    *   Das Ändern oder Entfernen des Verschlüsselungspassworts erfordert nun eine zwingende biometrische Authentifizierung.
    *   Auch das Deaktivieren der Verschlüsselung ist jetzt durch eine Biometrie-Abfrage geschützt, um unbefugtes Löschen des Schlüssels zu verhindern.
    *   Dies schließt die Lücke, bei der eine Person mit Zugriff auf das entsperrte Gerät die Daten hätte kompromittieren können.

2.  **Biometrie-Logik korrigiert:**
    *   Die Funktion 'Biometrisches Entsperren' funktioniert nun wie vorgesehen. Bei Aktivierung wird der Hauptschlüssel beim App-Start einmalig per Biometrie geladen.
    *   Dieser Schlüssel wird für die gesamte Sitzung wiederverwendet, sodass gesperrte Elemente ohne wiederholte Abfragen geöffnet werden können.
    *   Die Funktion kann über einen neuen Schalter in den Einstellungen jederzeit an- und ausgeschaltet werden.

3.  **Verbesserungen an der UI:**
    *   Die missverständliche Bezeichnung 'Syncronisationsordner verschlüsseln' wurde zu 'Datenverschlüsselung' korrigiert.

4.  **Kompatibilität des Entschlüsselungs-Skripts:**
    *   Das externe Skript decrypt.py wurde aktualisiert, um die neue, verschachtelte Verschlüsselungsstruktur zu verstehen und eine vollständige 'Tiefenentschlüsselung' der exportierten JSON-Dateien durchzuführen.
This commit is contained in:
2025-10-19 15:03:03 +02:00
parent 86bc492ce6
commit 6ba7f16e89
6 changed files with 503 additions and 270 deletions

View File

@@ -384,28 +384,72 @@ fun AppShell(
var showPasswordDialog by rememberSaveable { mutableStateOf(false) }
val onEncryptionToggle: (Boolean) -> Unit = { enabled ->
isEncryptionEnabled = enabled
sharedPrefs.edit {
putBoolean("encryption_enabled", enabled)
}
if (!enabled) {
// If encryption is disabled, remove the key
keyManager.deleteKey()
hasEncryptionPassword = false
if (!enabled && hasEncryptionPassword) {
// If turning off encryption, require auth to delete the key
val activity = context.findActivity() as FragmentActivity
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")
}
},
onFailed = {},
onError = { _, _ -> }
)
} else if (enabled) {
isEncryptionEnabled = true
sharedPrefs.edit {
putBoolean("encryption_enabled", true)
}
}
}
val onSetEncryptionPassword: () -> Unit = { showPasswordDialog = true }
val onRemoveEncryption: () -> Unit = {
keyManager.deleteKey()
hasEncryptionPassword = false
isEncryptionEnabled = false // Also disable the toggle
sharedPrefs.edit {
putBoolean("encryption_enabled", false)
val onSetEncryptionPassword: () -> Unit = {
if (hasEncryptionPassword) {
val activity = context.findActivity() as FragmentActivity
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.change_encryption_password),
subtitle = context.getString(R.string.confirm_to_change_password),
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
onSuccess = { showPasswordDialog = true },
onFailed = { },
onError = { _, _ -> }
)
} else {
showPasswordDialog = true
}
}
val onRemoveEncryption: () -> Unit = {
val activity = context.findActivity() as FragmentActivity
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")
}
},
onFailed = {},
onError = { _, _ -> }
)
}
val performAutomaticExport: suspend (Context, SecretKey?) -> Unit = remember(notesViewModel, shoppingListsViewModel, recipesViewModel, syncFolderUri) {
{ context, secretKey ->
syncFolderUri?.let { uri ->
@@ -572,7 +616,8 @@ fun AppShell(
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
if (isEncryptionEnabled && hasEncryptionPassword && !isDecryptionAttempted) {
val isBiometricUnlockEnabled = sharedPrefs.getBoolean("biometric_unlock_enabled", false)
if (isEncryptionEnabled && hasEncryptionPassword && isBiometricUnlockEnabled && !isDecryptionAttempted) {
scope.launch {
try {
val cipher = keyManager.getDecryptionCipher()
@@ -1535,38 +1580,55 @@ fun AppShell(
secretKey = secretKey,
fileEncryptor = fileEncryptor,
onUnlockClick = {
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_list),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
val listWithItems = shoppingListsViewModel.getShoppingListWithItemsStream(selectedListId!!).firstOrNull()
if (listWithItems != null && listWithItems.shoppingList.encryptedItems != null) {
try {
val decryptedItemsJson = String(fileEncryptor.decrypt(Base64.getDecoder().decode(listWithItems.shoppingList.encryptedItems), key), Charsets.UTF_8)
val decryptedItems = Json.decodeFromString<List<ShoppingListItem>>(decryptedItemsJson)
shoppingListsViewModel.unlockList(selectedListId!!, decryptedItems)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to decrypt list items.", e)
scope.launch {
if (secretKey != null) {
// Key already exists, decrypt directly
val listWithItems = shoppingListsViewModel.getShoppingListWithItemsStream(selectedListId!!).firstOrNull()
if (listWithItems != null && listWithItems.shoppingList.encryptedItems != null) {
try {
val decryptedItemsJson = String(fileEncryptor.decrypt(Base64.getDecoder().decode(listWithItems.shoppingList.encryptedItems), secretKey!!), Charsets.UTF_8)
val decryptedItems = Json.decodeFromString<List<ShoppingListItem>>(decryptedItemsJson)
shoppingListsViewModel.unlockList(selectedListId!!, decryptedItems)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to decrypt list items with session key.", 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_list),
subtitle = "",
negativeButtonText = context.getString(R.string.cancel),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
val key = keyManager.getSecretKeyFromAuthenticatedCipher(authenticatedCipher)
val listWithItems = shoppingListsViewModel.getShoppingListWithItemsStream(selectedListId!!).firstOrNull()
if (listWithItems != null && listWithItems.shoppingList.encryptedItems != null) {
try {
val decryptedItemsJson = String(fileEncryptor.decrypt(Base64.getDecoder().decode(listWithItems.shoppingList.encryptedItems), key), Charsets.UTF_8)
val decryptedItems = Json.decodeFromString<List<ShoppingListItem>>(decryptedItemsJson)
shoppingListsViewModel.unlockList(selectedListId!!, decryptedItems)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to decrypt list items.", e)
}
}
}
}
}
},
onFailed = {},
onError = { _, _ -> }
)
},
onFailed = {},
onError = { _, _ -> }
)
}
}
}
}
)
}
is Screen.NoteDetail -> {
@@ -1574,48 +1636,76 @@ fun AppShell(
noteId = selectedNoteId,
viewModel = notesViewModel,
onUnlockClick = {
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
}
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), key), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt note title, assuming it's plaintext.")
noteToDecrypt.title // Fallback to plaintext
}
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)
android.widget.Toast.makeText(context, "Decryption failed.", android.widget.Toast.LENGTH_LONG).show()
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 = { _, _ -> }
)
},
onFailed = {},
onError = { _, _ -> }
)
}
}
}
}
)
}
is Screen.Recipes -> {
@@ -1636,48 +1726,76 @@ fun AppShell(
recipeId = selectedRecipeId,
viewModel = recipesViewModel,
onUnlockClick = {
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
}
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), key), Charsets.UTF_8)
} catch (e: Exception) {
Log.w("MainActivity", "Could not decrypt recipe title, assuming it's plaintext.")
recipeToDecrypt.title // Fallback to plaintext
}
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)
android.widget.Toast.makeText(context, "Decryption failed.", android.widget.Toast.LENGTH_LONG).show()
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 = { _, _ -> }
)
},
onFailed = {},
onError = { _, _ -> }
)
}
}
}
}
)
}
is Screen.About -> {
@@ -1694,6 +1812,8 @@ fun AppShell(
onSetEncryptionPassword = onSetEncryptionPassword,
onRemoveEncryption = onRemoveEncryption,
hasEncryptionPassword = hasEncryptionPassword,
biometricAuthenticator = biometricAuthenticator,
sharedPrefs = sharedPrefs,
onResetShoppingListsTitle = {
sharedPrefs.edit {
remove("shopping_lists_title")
@@ -1726,7 +1846,8 @@ fun AppShell(
hasEncryptionPassword = true
showPasswordDialog = false
},
keyManager = keyManager
keyManager = keyManager,
sharedPrefs = sharedPrefs
)
}
}

View File

@@ -38,7 +38,8 @@ import kotlinx.coroutines.launch
fun EncryptionPasswordDialog(
onDismiss: () -> Unit,
onPasswordSet: () -> Unit,
keyManager: KeyManager
keyManager: KeyManager,
sharedPrefs: android.content.SharedPreferences
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -108,6 +109,7 @@ fun EncryptionPasswordDialog(
scope.launch {
try {
keyManager.generateAndStoreKey(password, useBiometrics)
sharedPrefs.edit().putBoolean("biometric_unlock_enabled", useBiometrics).apply()
onPasswordSet()
Toast.makeText(context, R.string.encryption_password_set_success, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {

View File

@@ -1,5 +1,6 @@
package de.lxtools.noteshop.ui
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -38,7 +39,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -47,6 +47,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import de.lxtools.noteshop.BiometricAuthenticator
import de.lxtools.noteshop.Screen
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@@ -61,6 +63,8 @@ fun SettingsScreen(
onSetEncryptionPassword: () -> Unit,
onRemoveEncryption: () -> Unit,
hasEncryptionPassword: Boolean,
biometricAuthenticator: BiometricAuthenticator,
sharedPrefs: SharedPreferences,
onResetShoppingListsTitle: () -> Unit,
onResetRecipesTitle: () -> Unit,
currentStartScreen: String,
@@ -74,6 +78,8 @@ fun SettingsScreen(
Screen.Recipes.route to stringResource(R.string.start_screen_recipes)
)
val currentStartScreenName = startScreenOptions[currentStartScreen] ?: stringResource(R.string.start_screen_last)
var isBiometricUnlockEnabled by remember { mutableStateOf(sharedPrefs.getBoolean("biometric_unlock_enabled", false)) }
Column(
modifier = Modifier
@@ -140,125 +146,157 @@ fun SettingsScreen(
Text(text = stringResource(R.string.reset_shopping_lists_name))
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onResetRecipesTitle, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.reset_recipes_name))
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.security),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
// Biometric check
val context = LocalContext.current
val biometricManager = BiometricManager.from(context)
val canUseBiometrics = remember {
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
}
Button(onClick = onResetRecipesTitle, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.reset_recipes_name))
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canUseBiometrics) { if (canUseBiometrics) onAppLockChange(!isAppLockEnabled) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.app_lock),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.alpha(if (canUseBiometrics) 1f else 0.5f)
)
Switch(
checked = isAppLockEnabled,
onCheckedChange = onAppLockChange,
enabled = canUseBiometrics
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEncryptionToggle(!isEncryptionEnabled) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.sync_folder_encryption),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f) // Allow text to take available space
)
Switch(
checked = isEncryptionEnabled,
onCheckedChange = onEncryptionToggle
)
}
if (isEncryptionEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onSetEncryptionPassword, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(if (hasEncryptionPassword) R.string.change_encryption_password else R.string.set_encryption_password))
}
if (hasEncryptionPassword) { Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onRemoveEncryption, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.remove_encryption))
} }
}
}
Text(
text = stringResource(R.string.security),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(16.dp))
// Biometric check
val context = LocalContext.current
val biometricManager = BiometricManager.from(context)
val canUseBiometrics = remember {
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
}
@Composable
private fun ThemeChooserItem(
theme: ColorTheme,
isSelected: Boolean,
onClick: () -> Unit
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canUseBiometrics) { if (canUseBiometrics) onAppLockChange(!isAppLockEnabled) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
val themeColor = when (theme) {
ColorTheme.STANDARD -> BluePrimary
ColorTheme.GREEN -> GreenPrimary
ColorTheme.VIOLET -> VioletPrimary
ColorTheme.YELLOW -> YellowPrimary
ColorTheme.ORANGE -> OrangePrimary
ColorTheme.RED -> RedPrimary
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onClick).padding(4.dp)
) {
Box(
Text(
text = stringResource(R.string.app_lock),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.alpha(if (canUseBiometrics) 1f else 0.5f)
)
Switch(
checked = isAppLockEnabled,
onCheckedChange = onAppLockChange,
enabled = canUseBiometrics
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEncryptionToggle(!isEncryptionEnabled) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.data_encryption),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f) // Allow text to take available space
)
Switch(
checked = isEncryptionEnabled,
onCheckedChange = onEncryptionToggle
)
}
if (isEncryptionEnabled) {
if (hasEncryptionPassword && canUseBiometrics) {
Row(
modifier = Modifier
.size(48.dp) // Reduced size
.clip(CircleShape)
.background(themeColor)
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onBackground, CircleShape)
} else {
Modifier
}
),
contentAlignment = Alignment.Center
.fillMaxWidth()
.clickable {
val newValue = !isBiometricUnlockEnabled
sharedPrefs.edit { putBoolean("biometric_unlock_enabled", newValue) }
isBiometricUnlockEnabled = newValue
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (isSelected) {
val checkmarkColor = if (theme == ColorTheme.YELLOW) Color.Black else Color.White
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.selected),
tint = checkmarkColor,
modifier = Modifier.size(24.dp) // Reduced size
)
}
Text(
text = stringResource(R.string.biometric_unlock),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Switch(
checked = isBiometricUnlockEnabled,
onCheckedChange = {
val newValue = it
sharedPrefs.edit { putBoolean("biometric_unlock_enabled", newValue) }
isBiometricUnlockEnabled = newValue
}
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onSetEncryptionPassword, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(if (hasEncryptionPassword) R.string.change_encryption_password else R.string.set_encryption_password))
}
if (hasEncryptionPassword) {
Spacer(modifier = Modifier.height(8.dp))
Text(text = stringResource(id = theme.titleRes), style = MaterialTheme.typography.bodyMedium)
Button(onClick = onRemoveEncryption, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.remove_encryption))
}
}
}
}
}
@Composable
private fun ThemeChooserItem(
theme: ColorTheme,
isSelected: Boolean,
onClick: () -> Unit
) {
val themeColor = when (theme) {
ColorTheme.STANDARD -> BluePrimary
ColorTheme.GREEN -> GreenPrimary
ColorTheme.VIOLET -> VioletPrimary
ColorTheme.YELLOW -> YellowPrimary
ColorTheme.ORANGE -> OrangePrimary
ColorTheme.RED -> RedPrimary
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable(onClick = onClick)
.padding(4.dp)
) {
Box(
modifier = Modifier
.size(48.dp) // Reduced size
.clip(CircleShape)
.background(themeColor)
.then(
if (isSelected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onBackground, CircleShape)
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
val checkmarkColor = if (theme == ColorTheme.YELLOW) Color.Black else Color.White
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.selected),
tint = checkmarkColor,
modifier = Modifier.size(24.dp) // Reduced size
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = stringResource(id = theme.titleRes), style = MaterialTheme.typography.bodyMedium)
}
}

View File

@@ -225,7 +225,7 @@
<string name="lock_note">Notiz sperren</string>
<string name="unlock_note">Sperre entfernen</string>
<string name="sync_folder_encryption">Synchronisationsordner verschlüsseln</string>
<string name="data_encryption">Datenverschlüsselung</string>
<string name="set_encryption_password">Verschlüsselungspasswort festlegen</string>
<string name="change_encryption_password">Verschlüsselungspasswort ändern</string>
<string name="remove_encryption">Verschlüsselung entfernen</string>
@@ -262,4 +262,8 @@
<string name="color_theme_yellow">Gelb</string>
<string name="color_theme_orange">Orange</string>
<string name="color_theme_red">Rot</string>
<string name="confirm_to_disable_encryption">Bestätigen, um die Verschlüsselung zu deaktivieren und zu entfernen</string>
<string name="confirm_to_change_password">Bestätigen, um das Verschlüsselungspasswort zu ändern</string>
<string name="confirm_to_remove_encryption">Bestätigen, um die Verschlüsselung dauerhaft zu entfernen</string>
<string name="biometric_unlock">Biometrisches Entsperren</string>
</resources>

View File

@@ -225,7 +225,7 @@
<string name="lock_note">Lock note</string>
<string name="unlock_note">Remove lock</string>
<string name="sync_folder_encryption">Sync Folder Encryption</string>
<string name="data_encryption">Data Encryption</string>
<string name="set_encryption_password">Set Encryption Password</string>
<string name="change_encryption_password">Change Encryption Password</string>
<string name="remove_encryption">Remove Encryption</string>
@@ -264,4 +264,8 @@
<string name="color_theme_red">Red</string>
<string name="error_corrupted_note">Note data is corrupted and cannot be decrypted.</string>
<string name="error_corrupted_recipe">Recipe data is corrupted and cannot be decrypted.</string>
<string name="confirm_to_disable_encryption">Confirm to disable and remove encryption</string>
<string name="confirm_to_change_password">Confirm to change encryption password</string>
<string name="confirm_to_remove_encryption">Confirm to permanently remove encryption</string>
<string name="biometric_unlock">Biometric Unlock</string>
</resources>

View File

@@ -1,5 +1,6 @@
import base64
import argparse
import json
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@@ -11,60 +12,123 @@ ITERATIONS = 10000
KEY_LENGTH_BYTES = 32 # 256 bits
IV_LENGTH_BYTES = 12
def derive_key(password, salt):
"""Leitet den Schlüssel aus dem Passwort und dem Salt ab."""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=KEY_LENGTH_BYTES,
salt=salt,
iterations=ITERATIONS
)
return kdf.derive(password.encode('utf-8'))
def decrypt_payload(encrypted_payload, key):
"""
Entschlüsselt eine einzelne Datennutzlast (IV + Chiffretext).
Gibt die entschlüsselten Bytes oder None bei einem Fehler zurück.
"""
if len(encrypted_payload) < IV_LENGTH_BYTES:
return None
iv = encrypted_payload[:IV_LENGTH_BYTES]
ciphertext = encrypted_payload[IV_LENGTH_BYTES:]
aesgcm = AESGCM(key)
try:
return aesgcm.decrypt(iv, ciphertext, None)
except InvalidTag:
return None
def process_decrypted_json(json_data, key):
"""
Durchläuft die JSON-Struktur und entschlüsselt verschachtelte Felder.
"""
if isinstance(json_data, list):
for item in json_data:
if not isinstance(item, dict):
continue
# Notizen und Rezepte: Entschlüsselt das 'content'-Feld
if 'content' in item and isinstance(item['content'], str):
try:
encrypted_content_bytes = base64.b64decode(item['content'])
decrypted_content_bytes = decrypt_payload(encrypted_content_bytes, key)
if decrypted_content_bytes is not None:
item['content'] = decrypted_content_bytes.decode('utf-8')
print(f" - Inhalt für '{item.get('title', 'Unbekannt')}' entschlüsselt.")
except (ValueError, TypeError, base64.binascii.Error):
# Inhalt ist kein Base64, also wahrscheinlich Klartext. Ignorieren.
pass
# Einkaufslisten: Entschlüsselt 'encryptedItems'
if 'shoppingList' in item and 'items' in item: # Struktur von ShoppingListWithItems
shopping_list = item['shoppingList']
if shopping_list.get('encryptedItems') and isinstance(shopping_list['encryptedItems'], str):
try:
encrypted_items_bytes = base64.b64decode(shopping_list['encryptedItems'])
decrypted_items_json_bytes = decrypt_payload(encrypted_items_bytes, key)
if decrypted_items_json_bytes is not None:
# Die entschlüsselte Nutzlast ist ein weiterer JSON-String (die Artikelliste)
item['items'] = json.loads(decrypted_items_json_bytes.decode('utf-8'))
shopping_list['encryptedItems'] = None
print(f" - Artikel für Einkaufsliste '{shopping_list.get('name', 'Unbekannt')}' entschlüsselt.")
except (ValueError, TypeError, base64.binascii.Error):
pass
return json_data
def decrypt_file(input_file, output_file, password):
"""
Decrypts a file encrypted by the Noteshop Android app.
Entschlüsselt eine von der Noteshop-App exportierte Datei und führt eine
tiefe Entschlüsselung für verschachtelte JSON-Inhalte durch.
"""
try:
# 1. Salt dekodieren
# 1. Schlüssel ableiten
salt = base64.b64decode(SALT_B64)
# 2. Schlüssel aus dem Passwort ableiten (PBKDF2)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=KEY_LENGTH_BYTES,
salt=salt,
iterations=ITERATIONS
)
key = kdf.derive(password.encode('utf-8'))
key = derive_key(password, salt)
print("Schlüssel erfolgreich abgeleitet.")
# 3. Verschlüsselte Datei lesen
# 2. Hauptdatei lesen und entschlüsseln
with open(input_file, 'rb') as f:
encrypted_data_with_iv = f.read()
encrypted_file_bytes = f.read()
# 4. IV und verschlüsselte Daten trennen
if len(encrypted_data_with_iv) < IV_LENGTH_BYTES:
print("Fehler: Die verschlüsselte Datei ist zu kurz.")
decrypted_data_bytes = decrypt_payload(encrypted_file_bytes, key)
if decrypted_data_bytes is None:
print("Fehler: Entschlüsselung der Hauptdatei fehlgeschlagen. Das Passwort ist höchstwahrscheinlich falsch oder die Datei ist beschädigt.")
return
iv = encrypted_data_with_iv[:IV_LENGTH_BYTES]
ciphertext = encrypted_data_with_iv[IV_LENGTH_BYTES:]
print(f"IV gefunden ({len(iv)} bytes), Chiffretext ({len(ciphertext)} bytes).")
# 3. Versuchen, als JSON zu verarbeiten für die tiefe Entschlüsselung
try:
decrypted_json_str = decrypted_data_bytes.decode('utf-8')
json_data = json.loads(decrypted_json_str)
print("Hauptdatei erfolgreich entschlüsselt. Führe Tiefenentschlüsselung für JSON-Inhalt durch...")
# 5. Daten mit AES/GCM entschlüsseln
aesgcm = AESGCM(key)
print("Entschlüsselung wird versucht...")
decrypted_data = aesgcm.decrypt(iv, ciphertext, None)
# 4. Tiefe Entschlüsselung durchführen
fully_decrypted_data = process_decrypted_json(json_data, key)
# 6. Entschlüsselte Daten in die Ausgabedatei schreiben
with open(output_file, 'wb') as f:
f.write(decrypted_data)
# 5. Vollständig entschlüsseltes JSON in die Ausgabedatei schreiben
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(fully_decrypted_data, f, ensure_ascii=False, indent=4)
print(f"Erfolg! Datei wurde erfolgreich tiefenentschlüsselt nach '{output_file}'.")
print(f"Erfolg! Datei wurde erfolgreich nach '{output_file}' entschlüsselt.")
except (json.JSONDecodeError, UnicodeDecodeError):
# Es war kein JSON, also die rohen entschlüsselten Daten schreiben (für Abwärtskompatibilität)
print("Warnung: Entschlüsselter Inhalt ist kein JSON. Schreibe rohe entschlüsselte Daten.")
with open(output_file, 'wb') as f:
f.write(decrypted_data_bytes)
print(f"Erfolg! Datei wurde (flach) entschlüsselt nach '{output_file}'.")
except InvalidTag:
print("Fehler: Entschlüsselung fehlgeschlagen. Das Passwort ist höchstwahrscheinlich falsch oder die Datei ist beschädigt.")
except Exception as e:
print(f"Ein unerwarteter Fehler ist aufgetreten: {e}")
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Entschlüsselt Noteshop-Dateien.")
parser.add_argument('-i', '--input', required=True, help="Pfad zur verschlüsselten Eingabedatei.")
parser.add_argument('-o', '--output', required=True, help="Pfad zur entschlüsselten Ausgabedatei.")
parser = argparse.ArgumentParser(description="Entschlüsselt Noteshop-Backup-Dateien (.json).")
parser.add_argument('-i', '--input', required=True, help="Pfad zur verschlüsselten Eingabedatei (z.B. notes.json).")
parser.add_argument('-o', '--output', required=True, help="Pfad zur vollständig entschlüsselten Ausgabedatei.")
parser.add_argument('-p', '--password', required=True, help="Das Verschlüsselungspasswort.")
args = parser.parse_args()
decrypt_file(args.input, args.output, args.password)
decrypt_file(args.input, args.output, args.password)