Fix(encryption): Resolve startup password unlock issues

- Fixed `Unresolved reference` error for startup password dialog variables by correctly declaring them in `AppShell.kt`.
- Added missing German translations for encryption-related strings in `strings.xml`.
- Corrected `onUnlockEncryption` logic in `AppShell.kt` to properly handle password-based decryption, addressing the "Unlock failed" error.
- Ensured the app closes when the `StartupPasswordDialog` is cancelled in `AppDialogs.kt`.
- Prevented automatic biometric binding when setting an encryption password in `EncryptionPasswordDialog.kt`, allowing users to choose password-only encryption.
- Adjusted biometric prompt behavior in `AppShell.kt` to allow multiple fingerprint attempts before falling back to the password dialog.
This commit is contained in:
2025-11-04 21:07:24 +01:00
parent 9abec4e66a
commit 2424068d59
6 changed files with 194 additions and 36 deletions

View File

@@ -78,6 +78,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.FileOutputStream
import javax.crypto.SecretKey
import android.util.Base64
import javax.crypto.spec.SecretKeySpec
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -95,6 +97,10 @@ fun AppShell(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showStartupPasswordDialog by rememberSaveable { mutableStateOf(false) }
var startupUnlockErrorMessage by rememberSaveable { mutableStateOf<String?>(null) }
var syncFolderUriString by rememberSaveable {
mutableStateOf(
sharedPrefs.getString(
@@ -518,44 +524,46 @@ fun AppShell(
Lifecycle.Event.ON_START -> {
val isBiometricUnlockEnabled =
sharedPrefs.getBoolean("biometric_unlock_enabled", false)
if (isEncryptionEnabled && hasEncryptionPassword && isBiometricUnlockEnabled && !isDecryptionAttempted) {
scope.launch {
try {
val cipher = keyManager.getDecryptionCipher()
if (cipher != null) {
val crypto =
androidx.biometric.BiometricPrompt.CryptoObject(cipher)
val activity = context.findActivity() as FragmentActivity
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_app),
subtitle = context.getString(R.string.confirm_to_unlock),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
secretKey =
keyManager.getSecretKeyFromAuthenticatedCipher(
authenticatedCipher
)
isDecryptionAttempted = true
if (isEncryptionEnabled && hasEncryptionPassword && !isDecryptionAttempted) {
if (isBiometricUnlockEnabled) {
scope.launch {
try {
val cipher = keyManager.getDecryptionCipher()
if (cipher != null) {
val crypto =
androidx.biometric.BiometricPrompt.CryptoObject(cipher)
val activity = context.findActivity() as FragmentActivity
biometricAuthenticator.promptBiometricAuth(
title = context.getString(R.string.unlock_app),
subtitle = context.getString(R.string.confirm_to_unlock),
fragmentActivity = activity,
crypto = crypto,
onSuccess = { result ->
result.cryptoObject?.cipher?.let { authenticatedCipher ->
scope.launch {
secretKey =
keyManager.getSecretKeyFromAuthenticatedCipher(
authenticatedCipher
)
isDecryptionAttempted = true
}
}
},
onFailed = {},
onError = { _, _ ->
showStartupPasswordDialog = true
}
},
onFailed = {
isDecryptionAttempted = true
}, // Proceed without key
onError = { _, _ ->
isDecryptionAttempted = true
} // Proceed without key
)
} else {
isDecryptionAttempted = true // No key/cipher, proceed
)
} else {
isDecryptionAttempted = true // No key/cipher, proceed
}
} catch (e: Exception) {
Log.e("AppShell", "Error during decryption prompt: ${e.message}")
isDecryptionAttempted = true // Proceed without key
}
} catch (e: Exception) {
Log.e("AppShell", "Error during decryption prompt: ${e.message}")
isDecryptionAttempted = true // Proceed without key
}
} else {
showStartupPasswordDialog = true
}
} else {
isDecryptionAttempted = true // No encryption, proceed
@@ -654,6 +662,9 @@ fun AppShell(
var itemToUnlockId by rememberSaveable { mutableStateOf<Int?>(null) }
var itemToUnlockType by rememberSaveable { mutableStateOf<Screen?>(null) }
var showChooseLockMethodDialog by rememberSaveable { mutableStateOf(false) }
var itemToLockId: Int? by rememberSaveable { mutableStateOf(null) }
var itemToLockType: LockableItemType? by rememberSaveable { mutableStateOf(null) }
@@ -1310,5 +1321,46 @@ fun AppShell(
},
canUseBiometrics = canUseBiometrics,
itemToLockType = itemToLockType,
showStartupPasswordDialog = showStartupPasswordDialog,
onShowStartupPasswordDialogChange = { showStartupPasswordDialog = it },
onUnlockEncryption = { password ->
scope.launch {
try {
val decryptionCipher = keyManager.getDecryptionCipher()
if (decryptionCipher == null) {
startupUnlockErrorMessage = context.getString(R.string.unlock_failed)
return@launch
}
val sharedPrefs = context.getSharedPreferences("encryption_prefs", Context.MODE_PRIVATE)
val encryptedDerivedKeyString = sharedPrefs.getString("encrypted_derived_key", null)
if (encryptedDerivedKeyString == null) {
startupUnlockErrorMessage = context.getString(R.string.unlock_failed)
return@launch
}
val encryptedDerivedKey = Base64.decode(encryptedDerivedKeyString, Base64.DEFAULT)
val decryptedStoredKeyBytes = decryptionCipher.doFinal(encryptedDerivedKey)
val currentPbeKey = keyManager.derivePbeKey(password.toCharArray())
Log.d("AppShell_Unlock", "Decrypted Stored Key: ${decryptedStoredKeyBytes.joinToString()}")
Log.d("AppShell_Unlock", "Current PBE Key: ${currentPbeKey.encoded.joinToString()}")
if (decryptedStoredKeyBytes.contentEquals(currentPbeKey.encoded)) {
secretKey = SecretKeySpec(decryptedStoredKeyBytes, "AES")
isDecryptionAttempted = true
showStartupPasswordDialog = false
startupUnlockErrorMessage = null
} else {
startupUnlockErrorMessage = context.getString(R.string.incorrect_password)
}
} catch (e: Exception) {
Log.e("AppShell", "Failed to unlock encryption with password", e)
startupUnlockErrorMessage = context.getString(R.string.unlock_failed)
}
}
},
startupUnlockErrorMessage = startupUnlockErrorMessage
)
}
}

View File

@@ -97,7 +97,7 @@ fun EncryptionPasswordDialog(
onClick = {
scope.launch {
try {
val useBiometrics = biometricAuthenticator.isBiometricAuthAvailable()
val useBiometrics = false
val pbeKey = keyManager.derivePbeKey(password.toCharArray())
val finalTask = { authorizedCipher: javax.crypto.Cipher ->

View File

@@ -0,0 +1,83 @@
package de.lxtools.noteshop.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StartupPasswordDialog(
onDismiss: () -> Unit,
onUnlock: (String) -> Unit,
errorMessage: String? = null
) {
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.unlock_encryption)) },
text = {
Column {
Text(text = stringResource(R.string.enter_password_to_unlock_encryption))
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (showPassword) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description = if (showPassword) "Hide password" else "Show password"
IconButton(onClick = { showPassword = !showPassword }) {
Icon(imageVector = image, description)
}
},
modifier = Modifier.fillMaxWidth()
)
errorMessage?.let {
Text(text = it, color = androidx.compose.material3.MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {
Button(
onClick = { onUnlock(password) },
enabled = password.isNotBlank()
) {
Text(stringResource(R.string.unlock))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}

View File

@@ -30,7 +30,10 @@ import de.lxtools.noteshop.ui.ChooseLockMethodDialog
import de.lxtools.noteshop.ui.LockableItemType
import de.lxtools.noteshop.ui.LockMethod
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import de.lxtools.noteshop.ui.StartupPasswordDialog
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
import de.lxtools.noteshop.findActivity
@Composable
fun AppDialogs(
@@ -99,6 +102,10 @@ fun AppDialogs(
onConfirmLockMethod: (LockMethod) -> Unit,
canUseBiometrics: Boolean,
itemToLockType: de.lxtools.noteshop.ui.LockableItemType?,
showStartupPasswordDialog: Boolean,
onShowStartupPasswordDialogChange: (Boolean) -> Unit,
onUnlockEncryption: (String) -> Unit,
startupUnlockErrorMessage: String?
) {
val scope = rememberCoroutineScope()
val dynamicRecipeStrings = getDynamicRecipeStrings(recipesTitle)
@@ -280,4 +287,16 @@ fun AppDialogs(
canUseBiometrics = canUseBiometrics
)
}
if (showStartupPasswordDialog) {
val context = LocalContext.current
StartupPasswordDialog(
onDismiss = {
onShowStartupPasswordDialogChange(false)
context.findActivity().finish()
},
onUnlock = onUnlockEncryption,
errorMessage = startupUnlockErrorMessage
)
}
}

View File

@@ -237,6 +237,8 @@
<string name="unlock_item">Element entsperren</string>
<string name="enter_password_to_unlock">Geben Sie das Passwort ein, um dieses Element zu entsperren.</string>
<string name="incorrect_password">Falsches Passwort.</string>
<string name="unlock_encryption">Verschlüsselung entsperren</string>
<string name="enter_password_to_unlock_encryption">Geben Sie das Passwort ein, um die Datenverschlüsselung zu entsperren.</string>
<string name="data_encryption">Datenverschlüsselung</string>
<string name="set_encryption_password">Verschlüsselungspasswort festlegen</string>

View File

@@ -238,6 +238,8 @@
<string name="unlock_item">Unlock Item</string>
<string name="enter_password_to_unlock">Enter password to unlock this item.</string>
<string name="incorrect_password">Incorrect password.</string>
<string name="unlock_encryption">Unlock Encryption</string>
<string name="enter_password_to_unlock_encryption">Enter password to unlock data encryption.</string>
<string name="data_encryption">Data Encryption</string>
<string name="set_encryption_password">Set Encryption Password</string>