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:
nrobi144
2026-07-01 11:35:52 +03:00
parent 292f8a0c78
commit f34c79c848
2 changed files with 123 additions and 4 deletions
@@ -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,
@@ -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