Integrate Android biometrics with the shared Rust lease (#98)
This commit is contained in:
1 parent
5c933a99a3
commit
647dc1bb4b
21 files changed
+1473
-118
No files matched your search
+147
@@ -0,0 +1,147 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
|
||||
@Composable
|
||||
fun AuthenticationControls(repository: AuthenticationRepository) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
var enrollment by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(repository) { repository.reload() }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { enrollment = false }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.auth_title))
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.biometricEnabled) R.string.auth_enabled else R.string.auth_disabled
|
||||
)
|
||||
)
|
||||
state.failure?.let { Text(it.title + "\n" + it.detail) }
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator()
|
||||
TextButton(onClick = repository::cancel) { Text(stringResource(R.string.auth_cancel)) }
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
if (state.biometricEnabled) repository.disable() else enrollment = true
|
||||
}) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.biometricEnabled) R.string.auth_disable else R.string.auth_enable
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!state.unlocked && state.biometricEnabled) {
|
||||
TextButton(onClick = repository::unlock) {
|
||||
Text(stringResource(R.string.password_unlock))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
enrollment = true
|
||||
}) { Text(stringResource(R.string.auth_manual)) }
|
||||
}
|
||||
if (state.unlocked) {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.auth_lease,
|
||||
state.remainingSeconds.toInt(),
|
||||
state.remainingSeconds.toLong()
|
||||
)
|
||||
)
|
||||
TextButton(onClick = repository::lock) {
|
||||
Text(stringResource(R.string.password_lock))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enrollment) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.auth_enroll_title),
|
||||
stringResource(R.string.auth_enroll_detail),
|
||||
onCancel = {
|
||||
enrollment = false
|
||||
repository.cancel()
|
||||
},
|
||||
onSubmit = {
|
||||
enrollment = false
|
||||
repository.enroll(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PassphraseDialog(
|
||||
title: String,
|
||||
detail: String,
|
||||
onCancel: () -> Unit,
|
||||
onSubmit: (String) -> Unit
|
||||
) {
|
||||
var secret by remember { mutableStateOf("") }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
secret = ""
|
||||
onCancel()
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { secret = "" } }
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = {
|
||||
secret = ""
|
||||
onCancel()
|
||||
},
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(detail)
|
||||
OutlinedTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
label = { Text(stringResource(R.string.setup_passphrase)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Password
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = secret.isNotEmpty(), onClick = {
|
||||
val submitted = secret
|
||||
secret = ""
|
||||
onSubmit(submitted)
|
||||
}) { Text(stringResource(R.string.auth_continue)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
secret = ""
|
||||
onCancel()
|
||||
}) {
|
||||
Text(stringResource(R.string.auth_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+57
-26
@@ -17,7 +17,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
@@ -26,7 +25,6 @@ import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -41,25 +39,33 @@ import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) {
|
||||
fun PasswordScreen(
|
||||
repository: PasswordRepository,
|
||||
authentication: AuthenticationRepository,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
var actions by remember { mutableStateOf(false) }
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
val lease by authentication.state.collectAsStateWithLifecycle()
|
||||
var manual by remember { mutableStateOf(false) }
|
||||
var requested by remember { mutableStateOf(false) }
|
||||
val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry }
|
||||
LaunchedEffect(entry?.id) {
|
||||
passphrase = ""
|
||||
manual = false
|
||||
requested = false
|
||||
navigator.navigateTo(
|
||||
if (entry ==
|
||||
null
|
||||
@@ -72,13 +78,13 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
||||
)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
passphrase = ""
|
||||
manual = false
|
||||
requested = false
|
||||
repository.lock()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (state.revision.isNotEmpty()) repository.refresh()
|
||||
}
|
||||
DisposableEffect(repository) { onDispose { repository.lock() } }
|
||||
BackHandler(state.selected != null || state.parent != null) {
|
||||
actions = false
|
||||
repository.back()
|
||||
@@ -217,28 +223,22 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
val sections = state.sections
|
||||
val sections = state.sections.takeIf { lease.unlocked }
|
||||
if (sections == null) {
|
||||
item {
|
||||
Text(entry.detail)
|
||||
OutlinedTextField(
|
||||
passphrase,
|
||||
{
|
||||
passphrase = it
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.setup_passphrase))
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !state.loading
|
||||
)
|
||||
Button(enabled = !state.loading, onClick = {
|
||||
val submitted = passphrase
|
||||
passphrase =
|
||||
""
|
||||
repository.unlock(submitted)
|
||||
Button(enabled = !state.loading && !lease.busy, onClick = {
|
||||
requested = true
|
||||
repository.unlock("")
|
||||
}) { Text(stringResource(R.string.password_unlock)) }
|
||||
TextButton(enabled = !state.loading && !lease.busy, onClick = {
|
||||
manual = true
|
||||
}) { Text(stringResource(R.string.auth_manual)) }
|
||||
if (lease.busy) {
|
||||
TextButton(onClick = authentication::cancel) {
|
||||
Text(stringResource(R.string.auth_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
@@ -274,6 +274,37 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
LaunchedEffect(lease.failure, state.loading) {
|
||||
if (requested && !state.loading && !lease.busy) {
|
||||
requested = false
|
||||
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
|
||||
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
|
||||
) {
|
||||
manual = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (manual && entry != null) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.auth_manual),
|
||||
stringResource(
|
||||
if (lease.biometricEnabled) {
|
||||
R.string.auth_recover_detail
|
||||
} else {
|
||||
R.string.auth_manual_detail
|
||||
}
|
||||
),
|
||||
onCancel = {
|
||||
manual = false
|
||||
authentication.cancel()
|
||||
},
|
||||
onSubmit = {
|
||||
manual = false
|
||||
requested = true
|
||||
repository.unlock(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (actions && state.selected != null && !state.loading) {
|
||||
val selected = requireNotNull(state.selected)
|
||||
AlertDialog(
|
||||
|
||||
@@ -91,10 +91,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
snapshot.storageRecovery == null &&
|
||||
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
||||
) {
|
||||
if (ready && snapshot.selectedTab == ShellTab.Preferences) {
|
||||
AuthenticationControls(viewModel.authentication)
|
||||
}
|
||||
OnboardingScreen(viewModel.onboarding, setupForm)
|
||||
} else if (browsing) {
|
||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
||||
PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f))
|
||||
} else if (home) {
|
||||
HomeScreen(viewModel.home, Modifier.weight(1f))
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.HomeRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
@@ -18,6 +19,7 @@ class ShellViewModel @Inject constructor(
|
||||
val onboarding: OnboardingRepository,
|
||||
val passwords: PasswordRepository,
|
||||
val home: HomeRepository,
|
||||
val authentication: AuthenticationRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
@@ -47,11 +49,6 @@ class ShellViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
fun selectTab(tab: ShellTab) {
|
||||
if (tab !=
|
||||
ShellTab.Passwords
|
||||
) {
|
||||
passwords.lock()
|
||||
}
|
||||
repository.selectTab(tab)
|
||||
}
|
||||
override fun onCleared() {
|
||||
|
||||
@@ -63,4 +63,20 @@
|
||||
<string name="password_close">Close</string>
|
||||
<string name="password_unlock">Unlock</string>
|
||||
<string name="password_lock">Lock</string>
|
||||
<string name="auth_title">Authentication</string>
|
||||
<string name="auth_enabled">Biometric unlock enabled</string>
|
||||
<string name="auth_disabled">Biometric unlock disabled</string>
|
||||
<string name="auth_enable">Enable biometric unlock</string>
|
||||
<string name="auth_disable">Disable biometric unlock</string>
|
||||
<string name="auth_manual">Use GPG passphrase</string>
|
||||
<string name="auth_manual_detail">The passphrase is validated by Rust and retained only for the authentication lease.</string>
|
||||
<string name="auth_recover_detail">After validating the passphrase, renew the enabled biometric record using a matching biometric.</string>
|
||||
<string name="auth_enroll_title">Enroll biometric unlock</string>
|
||||
<string name="auth_enroll_detail">Validate the GPG key passphrase, then use a strong biometric to protect it for future unlocks. A device PIN cannot substitute for a matching biometric.</string>
|
||||
<string name="auth_continue">Continue</string>
|
||||
<string name="auth_cancel">Cancel</string>
|
||||
<plurals name="auth_lease">
|
||||
<item quantity="one">Unlocked: %1$d second remaining</item>
|
||||
<item quantity="other">Unlocked: %1$d seconds remaining</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
Reference in new issue
Block a user