mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(privacylock): require password to disable + clear on remove
Toggling the Messages lock OFF now prompts for the current password via a new RemovePasswordDialog. On successful verification, the password hash is cleared AND the lock is disabled — re-enabling later requires setting a fresh password. Rationale: a user should not be able to disable the lock without proving they know the password. Clearing the hash on remove prevents a "silent re-enable" attack where someone toggles OFF then ON again and inherits the old password. Matches Signal PIN, WhatsApp Chat Lock, macOS FileVault disable posture. - New RemovePasswordDialog in SetPasswordDialog.kt: single Current password field, reveal toggle, red "Remove" button (colorScheme.error), Cancel + Escape dismiss. Auto-focus + Enter submits. Uses the same Dialog+Surface+DialogHeader shell as SetPasswordDialog. - DialogHeader refactored to take a title String (was isChange Bool). - PrivacyLockSettingsScreen toggle-off path routes through the new dialog when a hash exists. Corner case (lockEnabled=true but no hash — user manually cleared prefs) still disables directly. - On successful remove: setLockEnabled(false) + setPasswordHashed(null) + "Privacy lock removed" snackbar.
This commit is contained in:
+98
-3
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -133,7 +134,7 @@ fun SetPasswordDialog(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
DialogHeader(isChange = isChange)
|
||||
DialogHeader(title = if (isChange) "Change password" else "Set a password")
|
||||
|
||||
if (!isChange) {
|
||||
Text(
|
||||
@@ -192,7 +193,7 @@ fun SetPasswordDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogHeader(isChange: Boolean) {
|
||||
private fun DialogHeader(title: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -204,12 +205,106 @@ private fun DialogHeader(isChange: Boolean) {
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = if (isChange) "Change password" else "Set a password",
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the user's current password before removing the privacy lock.
|
||||
*
|
||||
* On successful verification, [onConfirm] is invoked with no arguments.
|
||||
* The caller is responsible for clearing `passwordHashed` and disabling
|
||||
* `lockEnabled` — this dialog only proves possession of the current
|
||||
* password.
|
||||
*
|
||||
* Deliberate design choice: users cannot disable the lock without
|
||||
* demonstrating they know the password, matching the security posture
|
||||
* of Signal PIN, WhatsApp Chat Lock, and macOS FileVault disable.
|
||||
*/
|
||||
@Composable
|
||||
fun RemovePasswordDialog(
|
||||
existingHash: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
var current by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val firstFieldFocus = remember { FocusRequester() }
|
||||
|
||||
val submit: () -> Unit = {
|
||||
if (PasswordHasher.verify(current.toCharArray(), existingHash)) {
|
||||
onConfirm()
|
||||
} else {
|
||||
error = "Wrong password"
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
firstFieldFocus.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.width(440.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
DialogHeader(title = "Remove password")
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Enter your current password to remove the lock. " +
|
||||
"You'll need to set a new password if you turn the lock back on later.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
PasswordField(
|
||||
value = current,
|
||||
onValueChange = {
|
||||
current = it
|
||||
error = null
|
||||
},
|
||||
label = "Current password",
|
||||
errorMessage = error,
|
||||
modifier = Modifier.focusRequester(firstFieldFocus),
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = { submit() },
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = submit,
|
||||
enabled = current.isNotEmpty(),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text("Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
value: String,
|
||||
|
||||
+25
-1
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.DmRedactionLevel
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.InactivityTimer
|
||||
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings
|
||||
import com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings
|
||||
import com.vitorpamplona.amethyst.desktop.security.RemovePasswordDialog
|
||||
import com.vitorpamplona.amethyst.desktop.security.SetPasswordDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -98,6 +99,7 @@ private fun LockToggleCard(
|
||||
val enabled by settings.lockEnabled.collectAsState()
|
||||
val stored by settings.passwordHashed.collectAsState()
|
||||
var showSetPassword by remember { mutableStateOf(false) }
|
||||
var showRemovePassword by remember { mutableStateOf(false) }
|
||||
var pendingEnable by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsCard(title = "Lock the Messages tab") {
|
||||
@@ -124,7 +126,14 @@ private fun LockToggleCard(
|
||||
settings.setLockEnabled(true)
|
||||
}
|
||||
} else {
|
||||
settings.setLockEnabled(false)
|
||||
// Disabling requires the current password. If none is set
|
||||
// (corner case — user cleared prefs manually), just
|
||||
// disable directly.
|
||||
if (stored != null) {
|
||||
showRemovePassword = true
|
||||
} else {
|
||||
settings.setLockEnabled(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -155,6 +164,21 @@ private fun LockToggleCard(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
stored?.let { hash ->
|
||||
if (showRemovePassword) {
|
||||
RemovePasswordDialog(
|
||||
existingHash = hash,
|
||||
onDismiss = { showRemovePassword = false },
|
||||
onConfirm = {
|
||||
settings.setLockEnabled(false)
|
||||
settings.setPasswordHashed(null)
|
||||
showRemovePassword = false
|
||||
onSaved("Privacy lock removed")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in New Issue
Block a user