Integrate Android biometrics with the shared Rust lease (#98)
Android ARM verification / rust (push) Failing after 47m23s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 16:32:01 +02:00
1 parent 5c933a99a3
commit 647dc1bb4b
21 files changed
+1473 -118

No files matched your search

@@ -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))
}
}
)
}
@@ -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>