Enforce Android lifecycle and presentation security (#100)
This commit is contained in:
1 parent
c0ba6d43d8
commit
a06f39af23
37 files changed
+1129
-110
No files matched your search
+5
-1
@@ -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)
|
||||
|
||||
+2
-1
@@ -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>
|
||||
Reference in new issue
Block a user