Enforce Android lifecycle and presentation security (#100)
Android ARM verification / rust (push) Failing after 47m6s
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 18:59:18 +02:00
1 parent c0ba6d43d8
commit a06f39af23
37 files changed
+1129 -110

No files matched your search

@@ -80,6 +80,7 @@ fun AuthenticationControls(repository: AuthenticationRepository) {
PassphraseDialog(
stringResource(R.string.auth_enroll_title),
stringResource(R.string.auth_enroll_detail),
presentation = repository.presentation,
onCancel = {
enrollment = false
repository.cancel()
@@ -96,15 +97,17 @@ fun AuthenticationControls(repository: AuthenticationRepository) {
internal fun PassphraseDialog(
title: String,
detail: String,
presentation: de.rfc1437.ironstorage.core.ProtectedPresentation,
onCancel: () -> Unit,
onSubmit: (String) -> Unit
) {
var secret by remember { mutableStateOf("") }
var secret by rememberProtectedInput(presentation)
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
secret = ""
onCancel()
}
DisposableEffect(Unit) { onDispose { secret = "" } }
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = {
@@ -114,6 +117,7 @@ internal fun PassphraseDialog(
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ProtectedInputSurface(presentation)
Text(detail)
OutlinedTextField(
value = secret,
@@ -59,7 +59,9 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
TextButton(enabled = !state.loading, onClick = { menu = true }) {
Text(stringResource(R.string.entry_more))
}
if (menu) ProtectedWindow()
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
ProtectedInputSurface()
PasswordMutation.entries.forEach { action ->
DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = {
menu = false
@@ -70,10 +72,12 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
})
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
if (edit) {
ProtectedWindow()
AlertDialog(
onDismissRequest = { edit = false },
title = { Text(stringResource(R.string.entry_edit)) },
text = {
ProtectedInputSurface()
Column(Modifier.verticalScroll(rememberScrollState())) {
state.sections?.flatMap {
it.fields
@@ -195,9 +199,13 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
}
@Composable
fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
fun EntryDialogs(
state: PasswordState,
repository: PasswordRepository,
authentication: de.rfc1437.ironstorage.core.AuthenticationRepository
) {
state.edit?.let { edit ->
var value by remember(edit) { mutableStateOf(edit.value) }
var value by rememberProtectedInput(authentication.presentation, edit.value)
var revealed by remember(edit) { mutableStateOf(false) }
val systemClipboard = LocalClipboard.current
val clipboard = remember(repository, edit.id, systemClipboard) {
@@ -208,16 +216,19 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
}
}
}
ProtectedWindow()
AlertDialog(
onDismissRequest = repository::dismissEdit,
title = { Text(edit.label) },
text = {
CompositionLocalProvider(LocalClipboard provides clipboard) {
Column {
ProtectedInputSurface(authentication.presentation)
OutlinedTextField(
value,
onValueChange = {
value = it
authentication.touch()
},
label = { Text(edit.label) },
singleLine = !edit.multiline,
@@ -245,7 +256,10 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
)
Text(stringResource(R.string.entry_copy_stored_hint))
if (edit.sensitive) {
TextButton(onClick = { revealed = !revealed }) {
TextButton(onClick = {
revealed = !revealed
authentication.touch()
}) {
Text(
stringResource(
if (revealed) R.string.entry_hide else R.string.entry_reveal
@@ -273,10 +287,12 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
var destination by remember(plan) { mutableStateOf<String?>(null) }
var overwrite by remember(plan, destination) { mutableStateOf(false) }
val selected = plan.destinations.find { it.path == destination }
ProtectedWindow()
AlertDialog(
onDismissRequest = repository::dismissMutation,
title = { Text(stringResource(plan.action.label())) },
text = {
ProtectedInputSurface()
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(plan.source)
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
@@ -203,6 +203,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
}
}
if (committing) {
ProtectedWindow()
AlertDialog(
onDismissRequest = {
committing = false
@@ -210,6 +211,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
},
title = { Text(stringResource(R.string.home_commit_title)) },
text = {
ProtectedInputSurface()
Column {
Text(state.name)
Text(state.email)
@@ -37,7 +37,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.OnboardingRepository
import javax.inject.Inject
/** Secrets survive configuration changes in memory, never in a saved-state Bundle. */
/** Setup inputs stay in memory and are synchronously cleared on presentation revocation. */
@HiltViewModel
class SetupFormViewModel @Inject constructor() : ViewModel() {
var passphrase by mutableStateOf("")
@@ -56,6 +56,7 @@ fun OnboardingScreen(
secrets: SetupFormViewModel,
modifier: Modifier = Modifier
) {
ProtectedInputSurface()
val state by repository.setup.collectAsStateWithLifecycle()
var step by rememberSaveable { mutableStateOf("choice") }
var clone by rememberSaveable { mutableStateOf(false) }
@@ -25,6 +25,7 @@ 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
@@ -57,6 +58,11 @@ fun PasswordScreen(
authentication: AuthenticationRepository,
modifier: Modifier = Modifier
) {
DisposableEffect(repository, authentication) {
onDispose {
if (authentication.state.value.unlocked) repository.lock()
}
}
val state by repository.state.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
var actions by remember { mutableStateOf(false) }
@@ -122,6 +128,12 @@ fun PasswordScreen(
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
lease.failure?.let {
Text(
it.title + "\n" + it.detail,
color = MaterialTheme.colorScheme.error
)
}
if (state.stale) Text(stringResource(R.string.password_changed))
}
}
@@ -260,7 +272,11 @@ fun PasswordScreen(
}
}
}
if (lease.unlocked && state.sections != null) EntryDialogs(state, repository)
if (lease.unlocked &&
state.sections != null
) {
EntryDialogs(state, repository, authentication)
}
if (manual && entry != null) {
PassphraseDialog(
stringResource(R.string.auth_manual),
@@ -271,6 +287,7 @@ fun PasswordScreen(
R.string.auth_manual_detail
}
),
presentation = authentication.presentation,
onCancel = {
manual = false
authentication.cancel()
@@ -284,12 +301,16 @@ fun PasswordScreen(
}
if (actions && state.selected != null && !state.loading) {
val selected = requireNotNull(state.selected)
ProtectedWindow()
AlertDialog(
onDismissRequest = { actions = false },
title = {
Text(selected.title)
},
text = { Text(selected.detail + "\n" + selected.path) },
text = {
ProtectedInputSurface()
Text(selected.detail + "\n" + selected.path)
},
confirmButton = {
TextButton(onClick = {
actions = false
@@ -0,0 +1,153 @@
package de.rfc1437.ironstorage.features
import android.graphics.Color
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.rfc1437.ironstorage.core.ProtectedPresentation
private val LocalProtectedPresentation = staticCompositionLocalOf<ProtectedPresentation?> { null }
private val LocalProtectedRoot = staticCompositionLocalOf<View?> { null }
/** Register before showing a dialog/popup so its focus transfer does not lock the activity. */
@Composable
internal fun ProtectedWindow() {
val presentation = LocalProtectedPresentation.current
DisposableEffect(presentation) {
val registration = presentation?.registerWindow()
onDispose { registration?.close() }
}
}
/** Hide the old native surface before clearing and rebuilding its presentation. */
@Composable
fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable () -> Unit) {
val view = LocalView.current.rootView
val surface = remember(view) { protectedSurface(view) }
val focused = remember(view) { mutableStateOf(view.hasWindowFocus()) }
val generation by presentation.generation.collectAsState()
val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
DisposableEffect(presentation, view) {
val registration = presentation.register(surface::cover)
val focus = ViewTreeObserver.OnWindowFocusChangeListener { focused.value = it }
view.viewTreeObserver.addOnWindowFocusChangeListener(focus)
onDispose {
registration.close()
if (view.viewTreeObserver.isAlive) {
view.viewTreeObserver.removeOnWindowFocusChangeListener(focus)
}
}
}
InterceptPlatformTextInput(interceptor = { request, next ->
next.startInputMethod(object : PlatformTextInputMethodRequest {
override fun createInputConnection(outAttributes: EditorInfo): InputConnection {
val connection = request.createInputConnection(outAttributes)
outAttributes.imeOptions = outAttributes.imeOptions or
EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING or
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI
return connection
}
})
}) {
CompositionLocalProvider(
LocalProtectedPresentation provides presentation,
LocalProtectedRoot provides view
) {
key(generation) { content() }
}
}
val revealCleanContent = focused.value && lifecycle.isAtLeast(Lifecycle.State.RESUMED)
SideEffect {
if (revealCleanContent) {
surface.uncover()
} else {
surface.cover()
}
}
}
@Composable
internal fun rememberProtectedInput(
presentation: ProtectedPresentation,
initial: String = ""
): MutableState<String> {
val value = remember(presentation) { mutableStateOf(initial) }
DisposableEffect(presentation) {
val registration = presentation.register { value.value = "" }
onDispose {
registration.close()
value.value = ""
}
}
return value
}
@Composable
internal fun ProtectedInputSurface(
presentation: ProtectedPresentation? = LocalProtectedPresentation.current
) {
val view = LocalView.current.rootView
val root = LocalProtectedRoot.current
val surface = remember(view) { protectedSurface(view) }
DisposableEffect(view, presentation) {
val registration = presentation?.register(surface::cover)
val focus = ViewTreeObserver.OnWindowFocusChangeListener {
if (!it) presentation?.onWindowFocusLost?.invoke()
}
if (view != root) view.viewTreeObserver.addOnWindowFocusChangeListener(focus)
view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
if (Build.VERSION.SDK_INT >= 30) {
view.importantForContentCapture =
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
}
onDispose {
registration?.close()
if (view != root && view.viewTreeObserver.isAlive) {
view.viewTreeObserver.removeOnWindowFocusChangeListener(focus)
}
}
}
key(presentation?.generation?.collectAsState()?.value) {
SideEffect { surface.uncover() }
}
}
private fun protectedSurface(view: View): ProtectedSurface =
(view.getTag(R.id.protected_surface) as? ProtectedSurface)
?: ProtectedSurface(view as ViewGroup).also { view.setTag(R.id.protected_surface, it) }
private class ProtectedSurface(private val view: ViewGroup) {
private val cover = View(view.context).apply { setBackgroundColor(Color.BLACK) }
private val accessibility = view.importantForAccessibility
private var covered = false
fun cover() {
view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
cover.layout(0, 0, view.width, view.height)
if (!covered) view.overlay.add(cover)
covered = true
}
fun uncover() {
view.overlay.remove(cover)
view.importantForAccessibility = accessibility
covered = false
}
}
@@ -0,0 +1,3 @@
<resources>
<item type="id" name="protected_surface" />
</resources>