Enforce Android lifecycle and presentation security (#100)
This commit is contained in:
1 parent
c0ba6d43d8
commit
a06f39af23
37 files changed
+1101
-82
No files matched your search
@@ -81,6 +81,8 @@ and [`docs/android/parity.md`](docs/android/parity.md). Android implementation
|
|||||||
belongs to milestone 06; the decision record does not claim a shipped client.
|
belongs to milestone 06; the decision record does not claim a shipped client.
|
||||||
The structured Android viewer and its validation are documented in
|
The structured Android viewer and its validation are documented in
|
||||||
[`docs/android/entry-viewer.md`](docs/android/entry-viewer.md).
|
[`docs/android/entry-viewer.md`](docs/android/entry-viewer.md).
|
||||||
|
Lifecycle, capture, input, and clipboard protections and platform limits are
|
||||||
|
documented in [`docs/android/presentation-security.md`](docs/android/presentation-security.md).
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
|
|||||||
+24
-4
@@ -1,6 +1,5 @@
|
|||||||
package de.rfc1437.ironstorage
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -11,6 +10,7 @@ import androidx.test.core.app.ActivityScenario
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import de.rfc1437.ironstorage.core.AndroidKeystore
|
import de.rfc1437.ironstorage.core.AndroidKeystore
|
||||||
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
|
||||||
import de.rfc1437.ironstorage.core.KeystoreAuthorization
|
import de.rfc1437.ironstorage.core.KeystoreAuthorization
|
||||||
import de.rfc1437.ironstorage.core.KeystoreCiphertext
|
import de.rfc1437.ironstorage.core.KeystoreCiphertext
|
||||||
import de.rfc1437.ironstorage.core.KeystoreKeySpecification
|
import de.rfc1437.ironstorage.core.KeystoreKeySpecification
|
||||||
@@ -47,7 +47,12 @@ class AuthenticationLeaseTest {
|
|||||||
private val context = instrumentation.targetContext
|
private val context = instrumentation.targetContext
|
||||||
|
|
||||||
@Test fun strongBiometricLeaseCancellationBackgroundAndManualRecovery() = runBlocking {
|
@Test fun strongBiometricLeaseCancellationBackgroundAndManualRecovery() = runBlocking {
|
||||||
assumeTrue(InstrumentationRegistry.getArguments().getString("biometricLease") == "true")
|
val securityOnly =
|
||||||
|
InstrumentationRegistry.getArguments().getString("biometricSecurity") == "true"
|
||||||
|
assumeTrue(
|
||||||
|
securityOnly ||
|
||||||
|
InstrumentationRegistry.getArguments().getString("biometricLease") == "true"
|
||||||
|
)
|
||||||
val os = AndroidKeystoreAdapter(context)
|
val os = AndroidKeystoreAdapter(context)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"Enroll a synthetic strong fingerprint for this opt-in test",
|
"Enroll a synthetic strong fingerprint for this opt-in test",
|
||||||
@@ -98,7 +103,7 @@ class AuthenticationLeaseTest {
|
|||||||
scenario.onActivity {
|
scenario.onActivity {
|
||||||
host = it
|
host = it
|
||||||
os.attach(it)
|
os.attach(it)
|
||||||
it.setContent {
|
it.setProtectedTestContent {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
Column {
|
Column {
|
||||||
Text(stage.collectAsState().value)
|
Text(stage.collectAsState().value)
|
||||||
@@ -140,9 +145,24 @@ class AuthenticationLeaseTest {
|
|||||||
request { authentication.unlock() }
|
request { authentication.unlock() }
|
||||||
instrumentation.waitForIdleSync()
|
instrumentation.waitForIdleSync()
|
||||||
scenario.moveToState(Lifecycle.State.CREATED)
|
scenario.moveToState(Lifecycle.State.CREATED)
|
||||||
request { authentication.lock() }
|
request { authentication.lifecycle(AuthenticationLifecycle.Background) }
|
||||||
assertFalse(settled().unlocked)
|
assertFalse(settled().unlocked)
|
||||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||||
|
if (securityOnly) {
|
||||||
|
request { authentication.lifecycle(AuthenticationLifecycle.Foreground) }
|
||||||
|
assertFalse(settled().unlocked)
|
||||||
|
show("100:unlock-after-background")
|
||||||
|
request { authentication.unlock() }
|
||||||
|
assertTrue(settled().unlocked)
|
||||||
|
request {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
|
||||||
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
}
|
||||||
|
request { authentication.disable() }
|
||||||
|
assertFalse(settled().biometricEnabled)
|
||||||
|
show("100:security-complete")
|
||||||
|
return@use
|
||||||
|
}
|
||||||
request { authentication.disable() }
|
request { authentication.disable() }
|
||||||
assertFalse(settled().biometricEnabled)
|
assertFalse(settled().biometricEnabled)
|
||||||
show("98:reenroll")
|
show("98:reenroll")
|
||||||
|
|||||||
+17
-4
@@ -1,6 +1,5 @@
|
|||||||
package de.rfc1437.ironstorage
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
@@ -26,7 +25,9 @@ class AuthenticationScreenTest {
|
|||||||
@Test fun enrollmentRequiresExplicitSubmissionAndTypedSuccessBeforeDisplayingEnabled() {
|
@Test fun enrollmentRequiresExplicitSubmissionAndTypedSuccessBeforeDisplayingEnabled() {
|
||||||
val repository = Fixture()
|
val repository = Fixture()
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } }
|
compose.activity.setProtectedTestContent {
|
||||||
|
MaterialTheme { AuthenticationControls(repository) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
|
compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed()
|
||||||
compose.onNodeWithText("Enable biometric unlock").performClick()
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
@@ -59,7 +60,9 @@ class AuthenticationScreenTest {
|
|||||||
@Test fun cancellationBackgroundAndRemountNeverRestoreThePassphrase() {
|
@Test fun cancellationBackgroundAndRemountNeverRestoreThePassphrase() {
|
||||||
val repository = Fixture()
|
val repository = Fixture()
|
||||||
fun mount() = compose.activity.runOnUiThread {
|
fun mount() = compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } }
|
compose.activity.setProtectedTestContent {
|
||||||
|
MaterialTheme { AuthenticationControls(repository) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mount()
|
mount()
|
||||||
compose.onNodeWithText("Enable biometric unlock").performClick()
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
@@ -73,7 +76,9 @@ class AuthenticationScreenTest {
|
|||||||
compose.onNodeWithText("GPG passphrase").assertDoesNotExist()
|
compose.onNodeWithText("GPG passphrase").assertDoesNotExist()
|
||||||
compose.onNodeWithText("Enable biometric unlock").performClick()
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
||||||
compose.activity.runOnUiThread { compose.activity.setContent { MaterialTheme { } } }
|
compose.activity.runOnUiThread {
|
||||||
|
compose.activity.setProtectedTestContent { MaterialTheme { } }
|
||||||
|
}
|
||||||
mount()
|
mount()
|
||||||
compose.onNodeWithText("Enable biometric unlock").performClick()
|
compose.onNodeWithText("Enable biometric unlock").performClick()
|
||||||
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
compose.onNodeWithText("Continue").assertIsNotEnabled()
|
||||||
@@ -82,6 +87,14 @@ class AuthenticationScreenTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class Fixture : AuthenticationRepository {
|
private class Fixture : AuthenticationRepository {
|
||||||
|
override val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
|
||||||
|
override fun lifecycle(event: de.rfc1437.ironstorage.core.AuthenticationLifecycle) {
|
||||||
|
if (event !=
|
||||||
|
de.rfc1437.ironstorage.core.AuthenticationLifecycle.Foreground
|
||||||
|
) {
|
||||||
|
lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
override val state = MutableStateFlow(AuthenticationState())
|
override val state = MutableStateFlow(AuthenticationState())
|
||||||
var submitted: String? = null
|
var submitted: String? = null
|
||||||
var locked = false
|
var locked = false
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package de.rfc1437.ironstorage
|
|||||||
|
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -58,7 +57,7 @@ class EntryViewerTest {
|
|||||||
@Test fun maskedFieldsExplicitActionsEditorDiagnosticsAndRevocation() {
|
@Test fun maskedFieldsExplicitActionsEditorDiagnosticsAndRevocation() {
|
||||||
val repository = Fixture()
|
val repository = Fixture()
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent {
|
compose.activity.setProtectedTestContent {
|
||||||
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +100,7 @@ class EntryViewerTest {
|
|||||||
val repository = Fixture()
|
val repository = Fixture()
|
||||||
repository.unlock("")
|
repository.unlock("")
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent {
|
compose.activity.setProtectedTestContent {
|
||||||
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +136,7 @@ class EntryViewerTest {
|
|||||||
val repository = Fixture()
|
val repository = Fixture()
|
||||||
val done = CountDownLatch(1)
|
val done = CountDownLatch(1)
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent {
|
compose.activity.setProtectedTestContent {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalDensity provides Density(
|
LocalDensity provides Density(
|
||||||
@@ -184,6 +183,14 @@ class EntryViewerTest {
|
|||||||
MutableStateFlow(PasswordState(rows = listOf(row), selected = row, revision = "r"))
|
MutableStateFlow(PasswordState(rows = listOf(row), selected = row, revision = "r"))
|
||||||
val lease = MutableStateFlow(AuthenticationState())
|
val lease = MutableStateFlow(AuthenticationState())
|
||||||
val authentication = object : AuthenticationRepository {
|
val authentication = object : AuthenticationRepository {
|
||||||
|
override val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
|
||||||
|
override fun lifecycle(event: de.rfc1437.ironstorage.core.AuthenticationLifecycle) {
|
||||||
|
if (event !=
|
||||||
|
de.rfc1437.ironstorage.core.AuthenticationLifecycle.Foreground
|
||||||
|
) {
|
||||||
|
lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
override val state = lease
|
override val state = lease
|
||||||
override fun reload() = Unit
|
override fun reload() = Unit
|
||||||
override fun unlock() = Unit
|
override fun unlock() = Unit
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.rfc1437.ironstorage
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
@@ -40,7 +39,7 @@ class HomeScreenTest {
|
|||||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||||
private fun show(repository: Fixture) {
|
private fun show(repository: Fixture) {
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent { MaterialTheme { HomeScreen(repository) } }
|
compose.activity.setProtectedTestContent { MaterialTheme { HomeScreen(repository) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.rfc1437.ironstorage
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
@@ -26,7 +25,9 @@ class OnboardingScreenTest {
|
|||||||
val repository = FixtureRepository()
|
val repository = FixtureRepository()
|
||||||
val secrets = SetupFormViewModel()
|
val secrets = SetupFormViewModel()
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent { MaterialTheme { OnboardingScreen(repository, secrets) } }
|
compose.activity.setProtectedTestContent {
|
||||||
|
MaterialTheme { OnboardingScreen(repository, secrets) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
compose.waitForIdle()
|
compose.waitForIdle()
|
||||||
compose.onNodeWithText("Create local store").performClick()
|
compose.onNodeWithText("Create local store").performClick()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package de.rfc1437.ironstorage
|
|||||||
|
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.input.InputMode
|
import androidx.compose.ui.input.InputMode
|
||||||
@@ -56,7 +55,7 @@ class PasswordScreenTest {
|
|||||||
val repository = Fixture()
|
val repository = Fixture()
|
||||||
lateinit var inputMode: InputModeManager
|
lateinit var inputMode: InputModeManager
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent {
|
compose.activity.setProtectedTestContent {
|
||||||
inputMode = LocalInputModeManager.current
|
inputMode = LocalInputModeManager.current
|
||||||
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||||
}
|
}
|
||||||
@@ -125,7 +124,7 @@ class PasswordScreenTest {
|
|||||||
repository.activate("folder-id", "root")
|
repository.activate("folder-id", "root")
|
||||||
repository.activate("entry-id", "folder")
|
repository.activate("entry-id", "folder")
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
compose.activity.setContent {
|
compose.activity.setProtectedTestContent {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
||||||
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
MaterialTheme { PasswordScreen(repository, repository.authentication) }
|
||||||
@@ -164,6 +163,14 @@ class PasswordScreenTest {
|
|||||||
MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder)))
|
MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder)))
|
||||||
val lease = MutableStateFlow(AuthenticationState())
|
val lease = MutableStateFlow(AuthenticationState())
|
||||||
val authentication = object : AuthenticationRepository {
|
val authentication = object : AuthenticationRepository {
|
||||||
|
override val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
|
||||||
|
override fun lifecycle(event: de.rfc1437.ironstorage.core.AuthenticationLifecycle) {
|
||||||
|
if (event !=
|
||||||
|
de.rfc1437.ironstorage.core.AuthenticationLifecycle.Foreground
|
||||||
|
) {
|
||||||
|
lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
override val state = lease
|
override val state = lease
|
||||||
override fun reload() = Unit
|
override fun reload() = Unit
|
||||||
override fun unlock() = error("Unexpected direct unlock")
|
override fun unlock() = error("Unexpected direct unlock")
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.ComponentCallbacks2
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.hasSetTextAction
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import de.rfc1437.ironstorage.features.ProtectedContent
|
||||||
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
|
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class PresentationSecurityTest {
|
||||||
|
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
|
||||||
|
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
|
||||||
|
val done = CountDownLatch(1)
|
||||||
|
lateinit var form: SetupFormViewModel
|
||||||
|
compose.runOnIdle {
|
||||||
|
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
|
||||||
|
form.passphrase = "public-task-switch-marker-100"
|
||||||
|
compose.activity.setProtectedTestContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Column {
|
||||||
|
Text("Task switching security fixture")
|
||||||
|
OutlinedTextField(
|
||||||
|
form.passphrase,
|
||||||
|
{ form.passphrase = it },
|
||||||
|
label = { Text("Disposable protected input") }
|
||||||
|
)
|
||||||
|
if (form.passphrase.isEmpty()) Text("Protected value cleared")
|
||||||
|
TextButton(onClick = {
|
||||||
|
done.countDown()
|
||||||
|
}) { Text("Finish security inspection") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val started = android.os.SystemClock.elapsedRealtime()
|
||||||
|
while (!done.await(100, TimeUnit.MILLISECONDS)) {
|
||||||
|
compose.mainClock.advanceTimeBy(100)
|
||||||
|
compose.waitForIdle()
|
||||||
|
assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
|
||||||
|
}
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("", form.passphrase)
|
||||||
|
assertFalse(
|
||||||
|
compose.activity.window.decorView.importantForAccessibility ==
|
||||||
|
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun inputSessionsRequestNoLearningAndNoKeyboardExtractWindow() {
|
||||||
|
val attributes = AtomicReference<EditorInfo?>()
|
||||||
|
compose.runOnIdle {
|
||||||
|
compose.activity.setContent {
|
||||||
|
InterceptPlatformTextInput(interceptor = { request, _ ->
|
||||||
|
val info = EditorInfo()
|
||||||
|
val connection = request.createInputConnection(info)
|
||||||
|
attributes.set(info)
|
||||||
|
try {
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
connection.closeConnection()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
ProtectedContent(compose.activity.authentication.presentation) {
|
||||||
|
MaterialTheme {
|
||||||
|
OutlinedTextField("", {}, label = { Text("Public input") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNode(hasSetTextAction()).performClick()
|
||||||
|
compose.waitUntil(10000) { attributes.get() != null }
|
||||||
|
val flags = attributes.get()!!.imeOptions
|
||||||
|
assertTrue(flags and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING != 0)
|
||||||
|
assertTrue(flags and EditorInfo.IME_FLAG_NO_FULLSCREEN != 0)
|
||||||
|
assertTrue(flags and EditorInfo.IME_FLAG_NO_EXTRACT_UI != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun clipboardOwnershipPreservesIdenticalNewerCopiesAndUserContent() {
|
||||||
|
compose.runOnIdle {
|
||||||
|
val manager = compose.activity.getSystemService(ClipboardManager::class.java)
|
||||||
|
val first = AndroidSecretClipboard(compose.activity).beginCopy()
|
||||||
|
val second = AndroidSecretClipboard(compose.activity).beginCopy()
|
||||||
|
first.write("public-same-value".toByteArray())
|
||||||
|
second.write("public-same-value".toByteArray())
|
||||||
|
assertNull(first.read())
|
||||||
|
first.clear()
|
||||||
|
assertEquals("public-same-value", second.read()!!.toString(Charsets.UTF_8))
|
||||||
|
manager.setPrimaryClip(ClipData.newPlainText("User", "public-same-value"))
|
||||||
|
second.clear()
|
||||||
|
assertEquals("public-same-value", manager.primaryClip!!.getItemAt(0).text.toString())
|
||||||
|
assertNull(second.read())
|
||||||
|
first.write("public-restored-value".toByteArray())
|
||||||
|
val restored = ClipData(manager.primaryClip!!)
|
||||||
|
android.os.SystemClock.sleep(2)
|
||||||
|
manager.setPrimaryClip(restored)
|
||||||
|
assertNull(first.read())
|
||||||
|
first.clear()
|
||||||
|
assertEquals(
|
||||||
|
"public-restored-value",
|
||||||
|
manager.primaryClip!!.getItemAt(0).text.toString()
|
||||||
|
)
|
||||||
|
second.write("owned public value".toByteArray())
|
||||||
|
second.clear()
|
||||||
|
assertTrue(
|
||||||
|
manager.primaryClip == null ||
|
||||||
|
manager.primaryClip!!.getItemAt(0).text.isNullOrEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@Test
|
||||||
|
fun memoryPressureMasksImmediatelyAndRecreationNeverSavesSetupSecrets() {
|
||||||
|
lateinit var form: SetupFormViewModel
|
||||||
|
compose.runOnIdle {
|
||||||
|
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
|
||||||
|
compose.activity.setProtectedTestContent {
|
||||||
|
MaterialTheme { Text("Public security fixture") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
|
||||||
|
fun populate() {
|
||||||
|
form.passphrase = "public-passphrase-marker-100"
|
||||||
|
form.armor = "public-armor-marker-100"
|
||||||
|
form.token = "public-token-marker-100"
|
||||||
|
}
|
||||||
|
fun assertCleared() {
|
||||||
|
assertEquals("", form.passphrase)
|
||||||
|
assertEquals("", form.armor)
|
||||||
|
assertEquals("", form.token)
|
||||||
|
}
|
||||||
|
compose.runOnIdle {
|
||||||
|
populate()
|
||||||
|
compose.activity.onWindowFocusChanged(false)
|
||||||
|
assertCleared()
|
||||||
|
compose.activity.onWindowFocusChanged(true)
|
||||||
|
}
|
||||||
|
compose.runOnIdle {
|
||||||
|
val window = compose.activity.window
|
||||||
|
populate()
|
||||||
|
(compose.activity.application as IronStorageApplication).onTrimMemory(
|
||||||
|
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW
|
||||||
|
)
|
||||||
|
assertCleared()
|
||||||
|
assertEquals(
|
||||||
|
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
|
||||||
|
window.decorView.importantForAccessibility
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE !=
|
||||||
|
0
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
|
||||||
|
window.decorView.importantForAutofill
|
||||||
|
)
|
||||||
|
if (Build.VERSION.SDK_INT >= 30) {
|
||||||
|
assertEquals(
|
||||||
|
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS,
|
||||||
|
window.decorView.importantForContentCapture
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
|
||||||
|
compose.runOnIdle {
|
||||||
|
populate()
|
||||||
|
val saved = Bundle()
|
||||||
|
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
|
||||||
|
compose.activity,
|
||||||
|
saved
|
||||||
|
)
|
||||||
|
val parcel = Parcel.obtain()
|
||||||
|
try {
|
||||||
|
parcel.writeBundle(saved)
|
||||||
|
val bytes = parcel.marshall()
|
||||||
|
for (marker in listOf(form.passphrase, form.armor, form.token)) {
|
||||||
|
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
|
||||||
|
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
parcel.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.activityRule.scenario.recreate()
|
||||||
|
compose.runOnIdle { assertCleared() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import de.rfc1437.ironstorage.features.ProtectedContent
|
||||||
|
|
||||||
|
/** Keep the production surface and IME protections around substituted test screens. */
|
||||||
|
internal fun MainActivity.setProtectedTestContent(content: @Composable () -> Unit) {
|
||||||
|
setContent { ProtectedContent(authentication.presentation, content) }
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.IronStorage"
|
android:theme="@style/Theme.IronStorage"
|
||||||
android:usesCleartextTraffic="false">
|
android:usesCleartextTraffic="false">
|
||||||
<activity android:name=".MainActivity" android:exported="true">
|
<activity android:name=".MainActivity" android:exported="true"
|
||||||
|
android:supportsPictureInPicture="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -2,6 +2,27 @@ package de.rfc1437.ironstorage
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class IronStorageApplication : Application()
|
class IronStorageApplication : Application() {
|
||||||
|
@Inject lateinit var authentication: AuthenticationRepository
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.ProcessRecreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||||
|
override fun onTrimMemory(level: Int) {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
|
||||||
|
super.onTrimMemory(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||||
|
override fun onLowMemory() {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.MemoryPressure)
|
||||||
|
super.onLowMemory()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
package de.rfc1437.ironstorage
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Display
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -8,9 +16,11 @@ import androidx.activity.viewModels
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
|
||||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
|
import de.rfc1437.ironstorage.features.ProtectedContent
|
||||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||||
@@ -24,23 +34,69 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var authentication: AuthenticationRepository
|
@Inject lateinit var authentication: AuthenticationRepository
|
||||||
|
|
||||||
@Inject lateinit var passwords: PasswordRepository
|
@Inject lateinit var onboarding: OnboardingRepository
|
||||||
|
|
||||||
private val setupForm: SetupFormViewModel by viewModels()
|
private val setupForm: SetupFormViewModel by viewModels()
|
||||||
|
|
||||||
private val viewModel: ShellViewModel by viewModels()
|
private val viewModel: ShellViewModel by viewModels()
|
||||||
|
|
||||||
|
private var cleanup: AutoCloseable? = null
|
||||||
|
private val protectedState = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == Intent.ACTION_SCREEN_OFF) {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.DeviceLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||||
|
super.onWindowFocusChanged(hasFocus)
|
||||||
|
if (!hasFocus && !keystore.authenticationPending &&
|
||||||
|
!authentication.presentation.hasOwnedWindow
|
||||||
|
) {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
passwords.lock()
|
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (getSystemService(KeyguardManager::class.java).isDeviceLocked) {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.DeviceLocked)
|
||||||
|
} else {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.Foreground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) {
|
||||||
|
super.onTopResumedActivityChanged(isTopResumedActivity)
|
||||||
|
if (!isTopResumedActivity && !keystore.authenticationPending) {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUserInteraction() {
|
override fun onUserInteraction() {
|
||||||
authentication.touch()
|
authentication.touch()
|
||||||
super.onUserInteraction()
|
super.onUserInteraction()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
cleanup?.close()
|
||||||
|
unregisterReceiver(protectedState)
|
||||||
keystore.detach(this)
|
keystore.detach(this)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
@@ -48,12 +104,38 @@ class MainActivity : FragmentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) setRecentsScreenshotEnabled(false)
|
||||||
|
window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||||
|
if (Build.VERSION.SDK_INT >= 30) {
|
||||||
|
window.decorView.importantForContentCapture =
|
||||||
|
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
|
||||||
|
}
|
||||||
|
cleanup = authentication.presentation.register {
|
||||||
|
setupForm.clearSecrets()
|
||||||
|
onboarding.cancel()
|
||||||
|
}
|
||||||
|
val filter = IntentFilter(Intent.ACTION_SCREEN_OFF)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
registerReceiver(protectedState, filter, RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||||
|
registerReceiver(protectedState, filter)
|
||||||
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val externalDisplay = windowManager.defaultDisplay.displayId != Display.DEFAULT_DISPLAY
|
||||||
|
if (externalDisplay) {
|
||||||
|
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
keystore.attach(this)
|
keystore.attach(this)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
|
ProtectedContent(authentication.presentation) {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
VariantContent(viewModel, repository, setupForm)
|
VariantContent(viewModel, repository, setupForm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
+23
@@ -113,6 +113,27 @@ class NativeEntryViewerTest {
|
|||||||
assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline })
|
assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline })
|
||||||
assertNotNull(repository.state.value.totp)
|
assertNotNull(repository.state.value.totp)
|
||||||
assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL)
|
assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL)
|
||||||
|
for (event in listOf(
|
||||||
|
AuthenticationLifecycle.Background,
|
||||||
|
AuthenticationLifecycle.DeviceLocked,
|
||||||
|
AuthenticationLifecycle.MemoryPressure,
|
||||||
|
AuthenticationLifecycle.ProcessRecreated
|
||||||
|
)) {
|
||||||
|
val field = fields().first()
|
||||||
|
request { repository.edit(field.id) }
|
||||||
|
request { repository.copy(field.id) }
|
||||||
|
withTimeout(10000) { while (clipboard.value == null) delay(20) }
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
authentication.lifecycle(event)
|
||||||
|
assertNull(repository.state.value.sections)
|
||||||
|
assertNull(repository.state.value.edit)
|
||||||
|
assertNull(repository.state.value.totp)
|
||||||
|
assertNull(clipboard.value)
|
||||||
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
}
|
||||||
|
request { repository.unlock(password) }
|
||||||
|
assertNotNull(repository.state.value.sections)
|
||||||
|
}
|
||||||
val pin = sensitive.single { it.label == "PIN" }
|
val pin = sensitive.single { it.label == "PIN" }
|
||||||
request { repository.reveal(pin.id) }
|
request { repository.reveal(pin.id) }
|
||||||
assertEquals("123493", fields().single { it.id == pin.id }.value)
|
assertEquals("123493", fields().single { it.id == pin.id }.value)
|
||||||
@@ -172,6 +193,8 @@ class NativeEntryViewerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private class MemoryClipboard : SecretClipboard {
|
private class MemoryClipboard : SecretClipboard {
|
||||||
|
override fun beginCopy(): SecretClipboard = this
|
||||||
|
|
||||||
@Volatile var value: ByteArray? = null
|
@Volatile var value: ByteArray? = null
|
||||||
override fun read() = value?.copyOf()
|
override fun read() = value?.copyOf()
|
||||||
override fun write(value: ByteArray) {
|
override fun write(value: ByteArray) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ data class AuthenticationState(
|
|||||||
val failure: AuthenticationFailure? = null
|
val failure: AuthenticationFailure? = null
|
||||||
)
|
)
|
||||||
interface AuthenticationRepository {
|
interface AuthenticationRepository {
|
||||||
|
val presentation: ProtectedPresentation
|
||||||
val state: StateFlow<AuthenticationState>
|
val state: StateFlow<AuthenticationState>
|
||||||
fun reload()
|
fun reload()
|
||||||
fun unlock()
|
fun unlock()
|
||||||
@@ -35,4 +36,5 @@ interface AuthenticationRepository {
|
|||||||
fun lock()
|
fun lock()
|
||||||
fun cancel()
|
fun cancel()
|
||||||
fun touch()
|
fun touch()
|
||||||
|
fun lifecycle(event: AuthenticationLifecycle)
|
||||||
}
|
}
|
||||||
+43
-8
@@ -7,6 +7,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind
|
|||||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationState
|
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationState
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileLifecycleEvent
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -26,6 +27,7 @@ class NativeAuthenticationRepository(
|
|||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val io: CoroutineDispatcher
|
private val io: CoroutineDispatcher
|
||||||
) : AuthenticationRepository {
|
) : AuthenticationRepository {
|
||||||
|
override val presentation = ProtectedPresentation()
|
||||||
private val mutable = MutableStateFlow(AuthenticationState())
|
private val mutable = MutableStateFlow(AuthenticationState())
|
||||||
override val state: StateFlow<AuthenticationState> = mutable
|
override val state: StateFlow<AuthenticationState> = mutable
|
||||||
private val sequence = AtomicLong()
|
private val sequence = AtomicLong()
|
||||||
@@ -37,6 +39,7 @@ class NativeAuthenticationRepository(
|
|||||||
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
|
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
|
||||||
}
|
}
|
||||||
init {
|
init {
|
||||||
|
presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(250)
|
delay(250)
|
||||||
@@ -45,7 +48,7 @@ class NativeAuthenticationRepository(
|
|||||||
try {
|
try {
|
||||||
val value = withContext(io) { current?.state() }
|
val value = withContext(io) { current?.state() }
|
||||||
if (ticket == sequence.get() && value != null) {
|
if (ticket == sequence.get() && value != null) {
|
||||||
mutable.value = value.toFacade()
|
publish(value.toFacade())
|
||||||
}
|
}
|
||||||
} catch (_: MobileAuthenticationFfiException) {
|
} catch (_: MobileAuthenticationFfiException) {
|
||||||
if (ticket == sequence.get()) lock()
|
if (ticket == sequence.get()) lock()
|
||||||
@@ -57,7 +60,10 @@ class NativeAuthenticationRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun owner(): MobileAuthentication =
|
private fun owner(): MobileAuthentication =
|
||||||
session.storage().authentication(session.backend(), clock).also { current = it }
|
session.storage().authentication(session.backend(), clock).also {
|
||||||
|
if (current == null) it.lifecycle(MobileLifecycleEvent.PROCESS_RECREATED)
|
||||||
|
current = it
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
|
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
|
||||||
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
||||||
@@ -78,14 +84,16 @@ class NativeAuthenticationRepository(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
val value = withContext(io) { current!!.state() }
|
val value = withContext(io) { current!!.state() }
|
||||||
if (ticket == sequence.get()) mutable.value = value.toFacade()
|
if (ticket == sequence.get()) publish(value.toFacade())
|
||||||
result
|
result
|
||||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||||
if (ticket == sequence.get()) {
|
if (ticket == sequence.get()) {
|
||||||
val value = withContext(io) { current?.state() }
|
val value = withContext(io) { current?.state() }
|
||||||
mutable.value = (value?.toFacade() ?: AuthenticationState()).copy(
|
publish(
|
||||||
|
(value?.toFacade() ?: AuthenticationState()).copy(
|
||||||
failure = error.toFacade()
|
failure = error.toFacade()
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
} catch (error: MobilePrivateStorageFfiException) {
|
} catch (error: MobilePrivateStorageFfiException) {
|
||||||
@@ -122,19 +130,26 @@ class NativeAuthenticationRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private fun publish(value: AuthenticationState) {
|
||||||
|
if (mutable.value.unlocked && !value.unlocked) presentation.clear()
|
||||||
|
mutable.value = value
|
||||||
|
}
|
||||||
private fun unavailable() {
|
private fun unavailable() {
|
||||||
mutable.value = AuthenticationState(
|
publish(
|
||||||
|
AuthenticationState(
|
||||||
failure = AuthenticationFailure(
|
failure = AuthenticationFailure(
|
||||||
AuthenticationFailureKind.Configuration,
|
AuthenticationFailureKind.Configuration,
|
||||||
"Authentication is unavailable",
|
"Authentication is unavailable",
|
||||||
"Unlock the device or recover private storage and reload."
|
"Unlock the device or recover private storage and reload."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
override fun lock() = revoke(false)
|
override fun lock() = revoke(false)
|
||||||
override fun cancel() = revoke(true)
|
override fun cancel() = revoke(true)
|
||||||
private fun revoke(cancel: Boolean) {
|
private fun revoke(cancel: Boolean, event: MobileLifecycleEvent? = null) {
|
||||||
val ticket = sequence.incrementAndGet()
|
val ticket = sequence.incrementAndGet()
|
||||||
|
presentation.clear()
|
||||||
mutable.value = mutable.value.copy(
|
mutable.value = mutable.value.copy(
|
||||||
unlocked = false,
|
unlocked = false,
|
||||||
remainingSeconds = 0u,
|
remainingSeconds = 0u,
|
||||||
@@ -151,9 +166,15 @@ class NativeAuthenticationRepository(
|
|||||||
)
|
)
|
||||||
session.cancelAuthentication()
|
session.cancelAuthentication()
|
||||||
val authentication = current
|
val authentication = current
|
||||||
|
val previous = revocation
|
||||||
revocation = scope.launch(io) {
|
revocation = scope.launch(io) {
|
||||||
|
previous?.join()
|
||||||
try {
|
try {
|
||||||
if (cancel) authentication?.cancel() else authentication?.manualLock()
|
when {
|
||||||
|
event != null -> authentication?.lifecycle(event)
|
||||||
|
cancel -> authentication?.cancel()
|
||||||
|
else -> authentication?.manualLock()
|
||||||
|
}
|
||||||
} catch (_: MobileAuthenticationFfiException) {
|
} catch (_: MobileAuthenticationFfiException) {
|
||||||
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
||||||
if (ticket == sequence.get()) unavailable()
|
if (ticket == sequence.get()) unavailable()
|
||||||
@@ -161,12 +182,26 @@ class NativeAuthenticationRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
override fun lifecycle(event: AuthenticationLifecycle) {
|
||||||
|
val native = when (event) {
|
||||||
|
AuthenticationLifecycle.Foreground -> MobileLifecycleEvent.FOREGROUND
|
||||||
|
AuthenticationLifecycle.Background -> MobileLifecycleEvent.BACKGROUND
|
||||||
|
AuthenticationLifecycle.DeviceLocked -> MobileLifecycleEvent.DEVICE_LOCKED
|
||||||
|
AuthenticationLifecycle.MemoryPressure -> MobileLifecycleEvent.MEMORY_PRESSURE
|
||||||
|
AuthenticationLifecycle.ProcessRecreated -> MobileLifecycleEvent.PROCESS_RECREATED
|
||||||
|
}
|
||||||
|
if (event == AuthenticationLifecycle.Foreground) {
|
||||||
|
run { it.lifecycle(native) }
|
||||||
|
} else {
|
||||||
|
revoke(false, native)
|
||||||
|
}
|
||||||
|
}
|
||||||
override fun touch() {
|
override fun touch() {
|
||||||
if (!mutable.value.unlocked || mutable.value.busy) return
|
if (!mutable.value.unlocked || mutable.value.busy) return
|
||||||
val ticket = sequence.get()
|
val ticket = sequence.get()
|
||||||
scope.launch(io) {
|
scope.launch(io) {
|
||||||
try {
|
try {
|
||||||
current?.touchUserActivity()
|
current?.lifecycle(MobileLifecycleEvent.USER_ACTIVITY)
|
||||||
} catch (_: MobileAuthenticationFfiException) {
|
} catch (_: MobileAuthenticationFfiException) {
|
||||||
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
||||||
if (ticket == sequence.get()) lock()
|
if (ticket == sequence.get()) lock()
|
||||||
|
|||||||
+63
-7
@@ -8,6 +8,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
|
|||||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome
|
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -26,7 +27,9 @@ class NativeOnboardingRepository(
|
|||||||
override val setup: StateFlow<SetupStatus> = mutableSetup
|
override val setup: StateFlow<SetupStatus> = mutableSetup
|
||||||
|
|
||||||
@Volatile private var operation: MobileAndroidOnboarding? = null
|
@Volatile private var operation: MobileAndroidOnboarding? = null
|
||||||
private var remote: MobileAndroidRemoteOnboarding? = null
|
|
||||||
|
@Volatile private var remote: MobileAndroidRemoteOnboarding? = null
|
||||||
|
private val revoked = AtomicBoolean()
|
||||||
|
|
||||||
@Volatile private var pollProgress = false
|
@Volatile private var pollProgress = false
|
||||||
|
|
||||||
@@ -46,7 +49,13 @@ class NativeOnboardingRepository(
|
|||||||
private fun newOperation(): MobileAndroidOnboarding {
|
private fun newOperation(): MobileAndroidOnboarding {
|
||||||
remote?.close()
|
remote?.close()
|
||||||
remote = null
|
remote = null
|
||||||
return session.storage().androidOnboarding(session.backend()).also { operation = it }
|
return session.storage().androidOnboarding(session.backend()).also {
|
||||||
|
operation = it
|
||||||
|
if (revoked.get()) {
|
||||||
|
it.cancel()
|
||||||
|
throw interrupted()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun generate(userId: String, passphrase: String) = runSetup {
|
override fun generate(userId: String, passphrase: String) = runSetup {
|
||||||
@@ -61,7 +70,15 @@ class NativeOnboardingRepository(
|
|||||||
override fun discover(server: String, account: String, repository: String, token: String) =
|
override fun discover(server: String, account: String, repository: String, token: String) =
|
||||||
runSetup {
|
runSetup {
|
||||||
val setup = newOperation()
|
val setup = newOperation()
|
||||||
val request = setup.remote(server, account, repository, token).also { remote = it }
|
val request = setup.remote(server, account, repository, token).also {
|
||||||
|
remote = it
|
||||||
|
if (revoked.get()) {
|
||||||
|
it.cancel()
|
||||||
|
it.close()
|
||||||
|
remote = null
|
||||||
|
throw interrupted()
|
||||||
|
}
|
||||||
|
}
|
||||||
val discovery = request.discover()
|
val discovery = request.discover()
|
||||||
pollProgress = false
|
pollProgress = false
|
||||||
mutableSetup.update {
|
mutableSetup.update {
|
||||||
@@ -82,7 +99,7 @@ class NativeOnboardingRepository(
|
|||||||
useExisting: Boolean
|
useExisting: Boolean
|
||||||
) = runSetup {
|
) = runSetup {
|
||||||
completed(
|
completed(
|
||||||
requireNotNull(remote).cloneStore(
|
(remote ?: throw interrupted()).cloneStore(
|
||||||
branch,
|
branch,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
passphrase.ifEmpty {
|
passphrase.ifEmpty {
|
||||||
@@ -108,8 +125,22 @@ class NativeOnboardingRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun cancel() {
|
override fun cancel() {
|
||||||
if (!mutableSetup.value.busy) return
|
revoked.set(true)
|
||||||
scope.launch(Dispatchers.IO) { operation?.cancel() }
|
pollProgress = false
|
||||||
|
mutableSetup.update { it.copy(branches = emptyList(), selectedBranch = "") }
|
||||||
|
val pending = remote
|
||||||
|
remote = null
|
||||||
|
val active = operation
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
active?.cancel()
|
||||||
|
pending?.cancel()
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// An operation that finished concurrently already released its handle.
|
||||||
|
} finally {
|
||||||
|
pending?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private fun completed(outcome: MobileOnboardingOutcome) {
|
private fun completed(outcome: MobileOnboardingOutcome) {
|
||||||
updateStatus(requireNotNull(operation).status())
|
updateStatus(requireNotNull(operation).status())
|
||||||
@@ -136,13 +167,18 @@ class NativeOnboardingRepository(
|
|||||||
}
|
}
|
||||||
private fun runSetup(action: () -> Unit) {
|
private fun runSetup(action: () -> Unit) {
|
||||||
if (mutableSetup.value.busy) return
|
if (mutableSetup.value.busy) return
|
||||||
|
revoked.set(false)
|
||||||
pollProgress = true
|
pollProgress = true
|
||||||
mutableSetup.update { it.copy(busy = true, failure = false) }
|
mutableSetup.update { it.copy(busy = true, failure = false) }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val progress = launch {
|
val progress = launch {
|
||||||
while (mutableSetup.value.busy) {
|
while (mutableSetup.value.busy) {
|
||||||
delay(200)
|
delay(200)
|
||||||
val value = withContext(Dispatchers.IO) { operation?.progress() }
|
val value = try {
|
||||||
|
withContext(Dispatchers.IO) { operation?.progress() }
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
value?.takeIf { mutableSetup.value.busy }?.let { phase ->
|
value?.takeIf { mutableSetup.value.busy }?.let { phase ->
|
||||||
mutableSetup.update {
|
mutableSetup.update {
|
||||||
if (pollProgress) {
|
if (pollProgress) {
|
||||||
@@ -198,11 +234,31 @@ class NativeOnboardingRepository(
|
|||||||
failure = true
|
failure = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} catch (error: IllegalStateException) {
|
||||||
|
if (!revoked.get()) throw error
|
||||||
|
val failure = interrupted()
|
||||||
|
mutableSetup.update {
|
||||||
|
it.copy(title = failure.title, detail = failure.detail, failure = true)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pollProgress = false
|
pollProgress = false
|
||||||
progress.cancel()
|
progress.cancel()
|
||||||
|
if (revoked.get()) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
remote?.close()
|
||||||
|
remote = null
|
||||||
|
operation?.close()
|
||||||
|
operation = null
|
||||||
|
}
|
||||||
|
}
|
||||||
mutableSetup.update { it.copy(busy = false) }
|
mutableSetup.update { it.copy(busy = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun interrupted() = MobileOnboardingFfiException.Failed(
|
||||||
|
MobileOnboardingErrorKind.INTERRUPTED,
|
||||||
|
"Setup interrupted",
|
||||||
|
"Return to setup and enter its secrets again."
|
||||||
|
)
|
||||||
+54
-25
@@ -14,8 +14,10 @@ import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
|
|||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.clearMobileEntryCopy
|
||||||
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
|
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -40,11 +42,15 @@ class NativePasswordRepository(
|
|||||||
private val sequence = AtomicLong()
|
private val sequence = AtomicLong()
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
val registration = authentication.presentation.register {
|
||||||
authentication.state.collect { lease ->
|
if (mutable.value.sections != null) {
|
||||||
if (!lease.unlocked) mutable.value = mutable.value.cleared()
|
sequence.incrementAndGet()
|
||||||
|
mutable.value = mutable.value.cleared().copy(loading = false)
|
||||||
|
} else {
|
||||||
|
mutable.value = mutable.value.cleared()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
@@ -53,7 +59,7 @@ class NativePasswordRepository(
|
|||||||
authentication.state.value.unlocked &&
|
authentication.state.value.unlocked &&
|
||||||
!authentication.state.value.busy
|
!authentication.state.value.busy
|
||||||
) {
|
) {
|
||||||
content { current ->
|
content(false) { current ->
|
||||||
val detail = authentication.authenticated { auth ->
|
val detail = authentication.authenticated { auth ->
|
||||||
auth.refreshEntryPresentation(
|
auth.refreshEntryPresentation(
|
||||||
requireNotNull(current.selected).path,
|
requireNotNull(current.selected).path,
|
||||||
@@ -231,32 +237,24 @@ class NativePasswordRepository(
|
|||||||
) = content { current ->
|
) = content { current ->
|
||||||
val copy = authentication.authenticated { get(it, current) }
|
val copy = authentication.authenticated { get(it, current) }
|
||||||
val ticket = sequence.get()
|
val ticket = sequence.get()
|
||||||
scope.launch(io) {
|
val owned = clipboard.beginCopy()
|
||||||
try {
|
val written = CompletableDeferred<Boolean>()
|
||||||
copyMobileEntryClearing(
|
val callback = object : MobileClipboard {
|
||||||
copy,
|
override fun read(): MobileClipboardContent = runBlocking(Dispatchers.Main.immediate) {
|
||||||
object : MobileClipboard {
|
clipboardAccess { owned.read() }?.let { MobileClipboardContent.Text(it) }
|
||||||
override fun read(): MobileClipboardContent =
|
|
||||||
runBlocking(Dispatchers.Main.immediate) {
|
|
||||||
clipboard.read()?.let { MobileClipboardContent.Text(it) }
|
|
||||||
?: MobileClipboardContent.EmptyOrNonText
|
?: MobileClipboardContent.EmptyOrNonText
|
||||||
}
|
}
|
||||||
override fun write(value: ByteArray) =
|
override fun write(value: ByteArray) = runBlocking(Dispatchers.Main.immediate) {
|
||||||
runBlocking(Dispatchers.Main.immediate) {
|
|
||||||
// A lock may have happened after Rust returned the copy value.
|
// A lock may have happened after Rust returned the copy value.
|
||||||
if (!authentication.state.value.unlocked) {
|
if (!authentication.state.value.unlocked) {
|
||||||
throw MobilePlatformFfiException.Cancelled()
|
throw MobilePlatformFfiException.Cancelled()
|
||||||
}
|
}
|
||||||
clipboard.write(value)
|
clipboardAccess { owned.write(value) }
|
||||||
if (ticket == sequence.get()) {
|
written.complete(true)
|
||||||
mutable.value = mutable.value.copy(
|
Unit
|
||||||
notice = "Copied. The clipboard will be " +
|
|
||||||
"cleared automatically."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
|
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
|
||||||
clipboard.clear()
|
clipboardAccess { owned.clear() }
|
||||||
}
|
}
|
||||||
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
||||||
val start = SystemClock.elapsedRealtime()
|
val start = SystemClock.elapsedRealtime()
|
||||||
@@ -271,19 +269,40 @@ class NativePasswordRepository(
|
|||||||
return MobileClipboardWait.ELAPSED
|
return MobileClipboardWait.ELAPSED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
val registration = withContext(Dispatchers.Main.immediate) {
|
||||||
|
authentication.presentation.register {
|
||||||
|
try {
|
||||||
|
clearMobileEntryCopy(copy, callback)
|
||||||
|
} catch (_: MobilePlatformFfiException) {
|
||||||
|
mutable.value = mutable.value.copy(error = "Clipboard cleanup is unavailable.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch(io) {
|
||||||
|
try {
|
||||||
|
copyMobileEntryClearing(copy, callback)
|
||||||
} catch (_: MobilePlatformFfiException.Cancelled) {
|
} catch (_: MobilePlatformFfiException.Cancelled) {
|
||||||
|
written.complete(false)
|
||||||
// Rust cleans up before returning cancellation.
|
// Rust cleans up before returning cancellation.
|
||||||
} catch (_: MobilePlatformFfiException) {
|
} catch (_: MobilePlatformFfiException) {
|
||||||
|
written.complete(false)
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
if (ticket == sequence.get()) {
|
if (ticket == sequence.get()) {
|
||||||
mutable.value =
|
mutable.value =
|
||||||
mutable.value.copy(error = "Copy is unavailable.", notice = null)
|
mutable.value.copy(error = "Copy is unavailable.", notice = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
withContext(kotlinx.coroutines.NonCancellable + Dispatchers.Main.immediate) {
|
||||||
|
registration.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
current
|
}
|
||||||
|
if (written.await()) {
|
||||||
|
current.copy(notice = "Copied. The clipboard will be cleared automatically.")
|
||||||
|
} else {
|
||||||
|
current.copy(error = "Copy is unavailable.", notice = null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun prepareMutation(action: PasswordMutation) = content { current ->
|
override fun prepareMutation(action: PasswordMutation) = content { current ->
|
||||||
val plan = authentication.authenticated {
|
val plan = authentication.authenticated {
|
||||||
@@ -326,7 +345,10 @@ class NativePasswordRepository(
|
|||||||
}
|
}
|
||||||
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
|
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
|
||||||
}
|
}
|
||||||
private fun content(action: suspend (PasswordState) -> PasswordState) {
|
private fun content(
|
||||||
|
userActivity: Boolean = true,
|
||||||
|
action: suspend (PasswordState) -> PasswordState
|
||||||
|
) {
|
||||||
val current = mutable.value
|
val current = mutable.value
|
||||||
if (current.sections == null || current.selected == null ||
|
if (current.sections == null || current.selected == null ||
|
||||||
current.documentRevision == null || !authentication.state.value.unlocked ||
|
current.documentRevision == null || !authentication.state.value.unlocked ||
|
||||||
@@ -334,6 +356,7 @@ class NativePasswordRepository(
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (userActivity) authentication.touch()
|
||||||
run(false) { action(current) }
|
run(false) { action(current) }
|
||||||
}
|
}
|
||||||
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
|
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
|
||||||
@@ -435,3 +458,9 @@ private fun PasswordMutation.native() = when (this) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun PasswordState.entryPath() = requireNotNull(selected).path
|
private fun PasswordState.entryPath() = requireNotNull(selected).path
|
||||||
|
|
||||||
|
private fun <T> clipboardAccess(action: () -> T): T = try {
|
||||||
|
action()
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
throw MobilePlatformFfiException.ClipboardFailed()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.rfc1437.ironstorage.core
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/** Main-thread presentation cleanup; Rust still owns authentication decisions. */
|
||||||
|
class ProtectedPresentation {
|
||||||
|
private var windows = 0
|
||||||
|
val hasOwnedWindow: Boolean get() = windows > 0
|
||||||
|
var onWindowFocusLost: () -> Unit = { clear() }
|
||||||
|
fun registerWindow(): AutoCloseable {
|
||||||
|
windows += 1
|
||||||
|
return AutoCloseable { windows -= 1 }
|
||||||
|
}
|
||||||
|
private val listeners = linkedSetOf<() -> Unit>()
|
||||||
|
private val mutableGeneration = MutableStateFlow(0L)
|
||||||
|
val generation: StateFlow<Long> = mutableGeneration
|
||||||
|
fun register(clear: () -> Unit): AutoCloseable {
|
||||||
|
synchronized(listeners) { listeners.add(clear) }
|
||||||
|
return AutoCloseable { synchronized(listeners) { listeners.remove(clear) } }
|
||||||
|
}
|
||||||
|
fun clear() {
|
||||||
|
synchronized(listeners) { listeners.toList() }.forEach { it() }
|
||||||
|
mutableGeneration.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AuthenticationLifecycle {
|
||||||
|
Foreground,
|
||||||
|
Background,
|
||||||
|
DeviceLocked,
|
||||||
|
MemoryPressure,
|
||||||
|
ProcessRecreated
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core
|
|||||||
|
|
||||||
/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */
|
/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */
|
||||||
interface SecretClipboard {
|
interface SecretClipboard {
|
||||||
|
fun beginCopy(): SecretClipboard
|
||||||
fun read(): ByteArray?
|
fun read(): ByteArray?
|
||||||
fun write(value: ByteArray)
|
fun write(value: ByteArray)
|
||||||
fun clear()
|
fun clear()
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.rfc1437.ironstorage.core
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProtectedPresentationTest {
|
||||||
|
@Test fun tracksOwnedWindowsUntilEveryWindowCloses() {
|
||||||
|
val presentation = ProtectedPresentation()
|
||||||
|
val first = presentation.registerWindow()
|
||||||
|
val second = presentation.registerWindow()
|
||||||
|
first.close()
|
||||||
|
assertTrue(presentation.hasOwnedWindow)
|
||||||
|
second.close()
|
||||||
|
assertFalse(presentation.hasOwnedWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun clearsSynchronouslyAndDropsDisposedRegistrations() {
|
||||||
|
val presentation = ProtectedPresentation()
|
||||||
|
var secret = "public-fixture"
|
||||||
|
val registration = presentation.register { secret = "" }
|
||||||
|
presentation.clear()
|
||||||
|
assertEquals("", secret)
|
||||||
|
assertEquals(1L, presentation.generation.value)
|
||||||
|
registration.close()
|
||||||
|
secret = "disposed"
|
||||||
|
presentation.clear()
|
||||||
|
assertEquals("disposed", secret)
|
||||||
|
assertEquals(2L, presentation.generation.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -80,6 +80,7 @@ fun AuthenticationControls(repository: AuthenticationRepository) {
|
|||||||
PassphraseDialog(
|
PassphraseDialog(
|
||||||
stringResource(R.string.auth_enroll_title),
|
stringResource(R.string.auth_enroll_title),
|
||||||
stringResource(R.string.auth_enroll_detail),
|
stringResource(R.string.auth_enroll_detail),
|
||||||
|
presentation = repository.presentation,
|
||||||
onCancel = {
|
onCancel = {
|
||||||
enrollment = false
|
enrollment = false
|
||||||
repository.cancel()
|
repository.cancel()
|
||||||
@@ -96,15 +97,17 @@ fun AuthenticationControls(repository: AuthenticationRepository) {
|
|||||||
internal fun PassphraseDialog(
|
internal fun PassphraseDialog(
|
||||||
title: String,
|
title: String,
|
||||||
detail: String,
|
detail: String,
|
||||||
|
presentation: de.rfc1437.ironstorage.core.ProtectedPresentation,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onSubmit: (String) -> Unit
|
onSubmit: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by rememberProtectedInput(presentation)
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||||
secret = ""
|
secret = ""
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) { onDispose { secret = "" } }
|
DisposableEffect(Unit) { onDispose { secret = "" } }
|
||||||
|
ProtectedWindow()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
@@ -114,6 +117,7 @@ internal fun PassphraseDialog(
|
|||||||
title = { Text(title) },
|
title = { Text(title) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ProtectedInputSurface(presentation)
|
||||||
Text(detail)
|
Text(detail)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
|||||||
TextButton(enabled = !state.loading, onClick = { menu = true }) {
|
TextButton(enabled = !state.loading, onClick = { menu = true }) {
|
||||||
Text(stringResource(R.string.entry_more))
|
Text(stringResource(R.string.entry_more))
|
||||||
}
|
}
|
||||||
|
if (menu) ProtectedWindow()
|
||||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
ProtectedInputSurface()
|
||||||
PasswordMutation.entries.forEach { action ->
|
PasswordMutation.entries.forEach { action ->
|
||||||
DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = {
|
DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = {
|
||||||
menu = false
|
menu = false
|
||||||
@@ -70,10 +72,12 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
|||||||
})
|
})
|
||||||
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
|
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
|
||||||
if (edit) {
|
if (edit) {
|
||||||
|
ProtectedWindow()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { edit = false },
|
onDismissRequest = { edit = false },
|
||||||
title = { Text(stringResource(R.string.entry_edit)) },
|
title = { Text(stringResource(R.string.entry_edit)) },
|
||||||
text = {
|
text = {
|
||||||
|
ProtectedInputSurface()
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
state.sections?.flatMap {
|
state.sections?.flatMap {
|
||||||
it.fields
|
it.fields
|
||||||
@@ -195,9 +199,13 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
|
fun EntryDialogs(
|
||||||
|
state: PasswordState,
|
||||||
|
repository: PasswordRepository,
|
||||||
|
authentication: de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
) {
|
||||||
state.edit?.let { edit ->
|
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) }
|
var revealed by remember(edit) { mutableStateOf(false) }
|
||||||
val systemClipboard = LocalClipboard.current
|
val systemClipboard = LocalClipboard.current
|
||||||
val clipboard = remember(repository, edit.id, systemClipboard) {
|
val clipboard = remember(repository, edit.id, systemClipboard) {
|
||||||
@@ -208,16 +216,19 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProtectedWindow()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = repository::dismissEdit,
|
onDismissRequest = repository::dismissEdit,
|
||||||
title = { Text(edit.label) },
|
title = { Text(edit.label) },
|
||||||
text = {
|
text = {
|
||||||
CompositionLocalProvider(LocalClipboard provides clipboard) {
|
CompositionLocalProvider(LocalClipboard provides clipboard) {
|
||||||
Column {
|
Column {
|
||||||
|
ProtectedInputSurface(authentication.presentation)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value,
|
value,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
value = it
|
value = it
|
||||||
|
authentication.touch()
|
||||||
},
|
},
|
||||||
label = { Text(edit.label) },
|
label = { Text(edit.label) },
|
||||||
singleLine = !edit.multiline,
|
singleLine = !edit.multiline,
|
||||||
@@ -245,7 +256,10 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
|
|||||||
)
|
)
|
||||||
Text(stringResource(R.string.entry_copy_stored_hint))
|
Text(stringResource(R.string.entry_copy_stored_hint))
|
||||||
if (edit.sensitive) {
|
if (edit.sensitive) {
|
||||||
TextButton(onClick = { revealed = !revealed }) {
|
TextButton(onClick = {
|
||||||
|
revealed = !revealed
|
||||||
|
authentication.touch()
|
||||||
|
}) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
if (revealed) R.string.entry_hide else R.string.entry_reveal
|
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 destination by remember(plan) { mutableStateOf<String?>(null) }
|
||||||
var overwrite by remember(plan, destination) { mutableStateOf(false) }
|
var overwrite by remember(plan, destination) { mutableStateOf(false) }
|
||||||
val selected = plan.destinations.find { it.path == destination }
|
val selected = plan.destinations.find { it.path == destination }
|
||||||
|
ProtectedWindow()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = repository::dismissMutation,
|
onDismissRequest = repository::dismissMutation,
|
||||||
title = { Text(stringResource(plan.action.label())) },
|
title = { Text(stringResource(plan.action.label())) },
|
||||||
text = {
|
text = {
|
||||||
|
ProtectedInputSurface()
|
||||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
Text(plan.source)
|
Text(plan.source)
|
||||||
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
|
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (committing) {
|
if (committing) {
|
||||||
|
ProtectedWindow()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
committing = false
|
committing = false
|
||||||
@@ -210,6 +211,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
|
|||||||
},
|
},
|
||||||
title = { Text(stringResource(R.string.home_commit_title)) },
|
title = { Text(stringResource(R.string.home_commit_title)) },
|
||||||
text = {
|
text = {
|
||||||
|
ProtectedInputSurface()
|
||||||
Column {
|
Column {
|
||||||
Text(state.name)
|
Text(state.name)
|
||||||
Text(state.email)
|
Text(state.email)
|
||||||
|
|||||||
+2
-1
@@ -37,7 +37,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
import javax.inject.Inject
|
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
|
@HiltViewModel
|
||||||
class SetupFormViewModel @Inject constructor() : ViewModel() {
|
class SetupFormViewModel @Inject constructor() : ViewModel() {
|
||||||
var passphrase by mutableStateOf("")
|
var passphrase by mutableStateOf("")
|
||||||
@@ -56,6 +56,7 @@ fun OnboardingScreen(
|
|||||||
secrets: SetupFormViewModel,
|
secrets: SetupFormViewModel,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
ProtectedInputSurface()
|
||||||
val state by repository.setup.collectAsStateWithLifecycle()
|
val state by repository.setup.collectAsStateWithLifecycle()
|
||||||
var step by rememberSaveable { mutableStateOf("choice") }
|
var step by rememberSaveable { mutableStateOf("choice") }
|
||||||
var clone by rememberSaveable { mutableStateOf(false) }
|
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.layout.ListDetailPaneScaffoldRole
|
||||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -57,6 +58,11 @@ fun PasswordScreen(
|
|||||||
authentication: AuthenticationRepository,
|
authentication: AuthenticationRepository,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
DisposableEffect(repository, authentication) {
|
||||||
|
onDispose {
|
||||||
|
if (authentication.state.value.unlocked) repository.lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
val state by repository.state.collectAsStateWithLifecycle()
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||||
var actions by remember { mutableStateOf(false) }
|
var actions by remember { mutableStateOf(false) }
|
||||||
@@ -122,6 +128,12 @@ fun PasswordScreen(
|
|||||||
state.error?.let {
|
state.error?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
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))
|
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) {
|
if (manual && entry != null) {
|
||||||
PassphraseDialog(
|
PassphraseDialog(
|
||||||
stringResource(R.string.auth_manual),
|
stringResource(R.string.auth_manual),
|
||||||
@@ -271,6 +287,7 @@ fun PasswordScreen(
|
|||||||
R.string.auth_manual_detail
|
R.string.auth_manual_detail
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
presentation = authentication.presentation,
|
||||||
onCancel = {
|
onCancel = {
|
||||||
manual = false
|
manual = false
|
||||||
authentication.cancel()
|
authentication.cancel()
|
||||||
@@ -284,12 +301,16 @@ fun PasswordScreen(
|
|||||||
}
|
}
|
||||||
if (actions && state.selected != null && !state.loading) {
|
if (actions && state.selected != null && !state.loading) {
|
||||||
val selected = requireNotNull(state.selected)
|
val selected = requireNotNull(state.selected)
|
||||||
|
ProtectedWindow()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { actions = false },
|
onDismissRequest = { actions = false },
|
||||||
title = {
|
title = {
|
||||||
Text(selected.title)
|
Text(selected.title)
|
||||||
},
|
},
|
||||||
text = { Text(selected.detail + "\n" + selected.path) },
|
text = {
|
||||||
|
ProtectedInputSurface()
|
||||||
|
Text(selected.detail + "\n" + selected.path)
|
||||||
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
actions = false
|
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>
|
||||||
+1
@@ -39,6 +39,7 @@ class AndroidKeystoreAdapter(context: Context) : AndroidKeystore {
|
|||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var activity = WeakReference<FragmentActivity>(null)
|
private var activity = WeakReference<FragmentActivity>(null)
|
||||||
private val pending = AtomicReference<Pending?>()
|
private val pending = AtomicReference<Pending?>()
|
||||||
|
val authenticationPending: Boolean get() = pending.get() != null
|
||||||
|
|
||||||
fun attach(host: FragmentActivity) {
|
fun attach(host: FragmentActivity) {
|
||||||
activity = WeakReference(host)
|
activity = WeakReference(host)
|
||||||
|
|||||||
+37
-5
@@ -1,29 +1,61 @@
|
|||||||
package de.rfc1437.ironstorage.platform
|
package de.rfc1437.ironstorage.platform
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PersistableBundle
|
import android.os.PersistableBundle
|
||||||
import de.rfc1437.ironstorage.core.SecretClipboard
|
import de.rfc1437.ironstorage.core.SecretClipboard
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class AndroidSecretClipboard(context: Context) : SecretClipboard {
|
class AndroidSecretClipboard(private val context: Context) : SecretClipboard {
|
||||||
private val manager = context.getSystemService(ClipboardManager::class.java)
|
private val manager = context.getSystemService(ClipboardManager::class.java)
|
||||||
override fun read(): ByteArray? = manager.primaryClip?.let { clip ->
|
private val identity = UUID.randomUUID().toString()
|
||||||
|
private var timestamp: Long? = null
|
||||||
|
override fun beginCopy(): SecretClipboard = AndroidSecretClipboard(context)
|
||||||
|
private fun owns(clip: ClipData) = clip.description.extras?.getString(OWNER) == identity &&
|
||||||
|
timestamp != null && clip.description.timestamp == timestamp
|
||||||
|
override fun read(): ByteArray? = access {
|
||||||
|
manager.primaryClip?.takeIf(::owns)?.let { clip ->
|
||||||
if (clip.itemCount == 1) clip.getItemAt(0).text?.toString()?.toByteArray() else null
|
if (clip.itemCount == 1) clip.getItemAt(0).text?.toString()?.toByteArray() else null
|
||||||
}
|
}
|
||||||
override fun write(value: ByteArray) {
|
}
|
||||||
|
override fun write(value: ByteArray) = access {
|
||||||
val clip = ClipData.newPlainText("IronStorage", value.toString(Charsets.UTF_8))
|
val clip = ClipData.newPlainText("IronStorage", value.toString(Charsets.UTF_8))
|
||||||
clip.description.extras = PersistableBundle().apply {
|
clip.description.extras = PersistableBundle().apply {
|
||||||
putBoolean("android.content.extra.IS_SENSITIVE", true)
|
putBoolean(
|
||||||
|
if (Build.VERSION.SDK_INT >=
|
||||||
|
33
|
||||||
|
) {
|
||||||
|
ClipDescription.EXTRA_IS_SENSITIVE
|
||||||
|
} else {
|
||||||
|
"android.content.extra.IS_SENSITIVE"
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
putString(OWNER, identity)
|
||||||
}
|
}
|
||||||
manager.setPrimaryClip(clip)
|
manager.setPrimaryClip(clip)
|
||||||
|
timestamp = manager.primaryClipDescription?.takeIf {
|
||||||
|
it.extras?.getString(OWNER) == identity
|
||||||
|
}?.timestamp
|
||||||
}
|
}
|
||||||
override fun clear() {
|
override fun clear() = access {
|
||||||
|
if (manager.primaryClip?.let(::owns) != true) return@access
|
||||||
if (Build.VERSION.SDK_INT >= 28) {
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
manager.clearPrimaryClip()
|
manager.clearPrimaryClip()
|
||||||
} else {
|
} else {
|
||||||
manager.setPrimaryClip(ClipData.newPlainText("", ""))
|
manager.setPrimaryClip(ClipData.newPlainText("", ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private fun <T> access(block: () -> T): T = try {
|
||||||
|
block()
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
throw IOException("Android clipboard access is unavailable.")
|
||||||
|
}
|
||||||
|
private companion object {
|
||||||
|
const val OWNER = "de.rfc1437.ironstorage.clipboard-owner"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +282,8 @@ pub enum MobileLifecycleEvent {
|
|||||||
Foreground,
|
Foreground,
|
||||||
Background,
|
Background,
|
||||||
DeviceLocked,
|
DeviceLocked,
|
||||||
|
MemoryPressure,
|
||||||
|
ProcessRecreated,
|
||||||
UserActivity,
|
UserActivity,
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
@@ -291,6 +293,8 @@ impl From<MobileLifecycleEvent> for storage::MobileLifecycleEvent {
|
|||||||
MobileLifecycleEvent::Foreground => Self::Foreground,
|
MobileLifecycleEvent::Foreground => Self::Foreground,
|
||||||
MobileLifecycleEvent::Background => Self::Background,
|
MobileLifecycleEvent::Background => Self::Background,
|
||||||
MobileLifecycleEvent::DeviceLocked => Self::DeviceLocked,
|
MobileLifecycleEvent::DeviceLocked => Self::DeviceLocked,
|
||||||
|
MobileLifecycleEvent::MemoryPressure => Self::MemoryPressure,
|
||||||
|
MobileLifecycleEvent::ProcessRecreated => Self::ProcessRecreated,
|
||||||
MobileLifecycleEvent::UserActivity => Self::UserActivity,
|
MobileLifecycleEvent::UserActivity => Self::UserActivity,
|
||||||
MobileLifecycleEvent::Cancel => Self::Cancel,
|
MobileLifecycleEvent::Cancel => Self::Cancel,
|
||||||
}
|
}
|
||||||
@@ -404,6 +408,21 @@ pub fn copy_mobile_entry_clearing(
|
|||||||
copy_entry(copy, clipboard, true)
|
copy_entry(copy, clipboard, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Revoke a copy while the Android window still has foreground clipboard access.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn clear_mobile_entry_copy(
|
||||||
|
copy: MobileEntryCopy,
|
||||||
|
clipboard: Arc<dyn MobileClipboard>,
|
||||||
|
) -> Result<(), MobilePlatformFfiError> {
|
||||||
|
presentation::ClipboardManager::new(
|
||||||
|
ClipboardAdapter(clipboard),
|
||||||
|
presentation::ClipboardTimeout::default(),
|
||||||
|
)
|
||||||
|
.clear_current_copy(&SecretBytes::new(copy.value.into_bytes()))
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|_| MobilePlatformFfiError::ClipboardFailed)
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_entry(
|
fn copy_entry(
|
||||||
copy: MobileEntryCopy,
|
copy: MobileEntryCopy,
|
||||||
clipboard: Arc<dyn MobileClipboard>,
|
clipboard: Arc<dyn MobileClipboard>,
|
||||||
|
|||||||
@@ -1609,9 +1609,10 @@ impl MobileAuthentication {
|
|||||||
use crate::mobile_platform::MobileLifecycleEvent;
|
use crate::mobile_platform::MobileLifecycleEvent;
|
||||||
match event {
|
match event {
|
||||||
MobileLifecycleEvent::Foreground => {}
|
MobileLifecycleEvent::Foreground => {}
|
||||||
MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => {
|
MobileLifecycleEvent::Background
|
||||||
self.manual_lock()?
|
| MobileLifecycleEvent::DeviceLocked
|
||||||
}
|
| MobileLifecycleEvent::MemoryPressure
|
||||||
|
| MobileLifecycleEvent::ProcessRecreated => self.manual_lock()?,
|
||||||
MobileLifecycleEvent::UserActivity => {
|
MobileLifecycleEvent::UserActivity => {
|
||||||
if self.state()?.unlocked() {
|
if self.state()?.unlocked() {
|
||||||
self.touch_user_activity()?;
|
self.touch_user_activity()?;
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ pub enum MobileLifecycleEvent {
|
|||||||
Foreground,
|
Foreground,
|
||||||
Background,
|
Background,
|
||||||
DeviceLocked,
|
DeviceLocked,
|
||||||
|
MemoryPressure,
|
||||||
|
ProcessRecreated,
|
||||||
UserActivity,
|
UserActivity,
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,14 @@ impl<B: ClipboardBackend> ClipboardManager<B> {
|
|||||||
Ok(disposition)
|
Ok(disposition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear only the current copy, without waiting or retaining previous data.
|
||||||
|
pub fn clear_current_copy(
|
||||||
|
&mut self,
|
||||||
|
copied: &SecretBytes,
|
||||||
|
) -> Result<ClipboardDisposition, ClipboardError> {
|
||||||
|
self.cleanup(copied, &ClipboardContent::EmptyOrNonText)
|
||||||
|
}
|
||||||
|
|
||||||
fn cleanup(
|
fn cleanup(
|
||||||
&mut self,
|
&mut self,
|
||||||
copied: &SecretBytes,
|
copied: &SecretBytes,
|
||||||
|
|||||||
@@ -85,6 +85,28 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
|||||||
authentication.lifecycle(MobileLifecycleEvent::Background)?;
|
authentication.lifecycle(MobileLifecycleEvent::Background)?;
|
||||||
assert!(!authentication.state()?.unlocked());
|
assert!(!authentication.state()?.unlocked());
|
||||||
assert!(authentication.entry_editor(editor.id()).is_err());
|
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||||
|
for event in [
|
||||||
|
MobileLifecycleEvent::DeviceLocked,
|
||||||
|
MobileLifecycleEvent::MemoryPressure,
|
||||||
|
MobileLifecycleEvent::ProcessRecreated,
|
||||||
|
] {
|
||||||
|
authentication.unlock_entry(
|
||||||
|
"email/personal",
|
||||||
|
Some(SecretBytes::new(
|
||||||
|
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||||
|
)),
|
||||||
|
)?;
|
||||||
|
let editor = authentication.begin_entry_editor("email/personal")?;
|
||||||
|
clock.advance(Duration::from_secs(5));
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.lifecycle(MobileLifecycleEvent::Foreground)?
|
||||||
|
.remaining_seconds(),
|
||||||
|
25
|
||||||
|
);
|
||||||
|
assert!(!authentication.lifecycle(event)?.unlocked());
|
||||||
|
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||||
|
}
|
||||||
let baseline_locks = backend.locks();
|
let baseline_locks = backend.locks();
|
||||||
drop(authentication);
|
drop(authentication);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -165,6 +165,28 @@ fn clearing_clipboard_never_reads_or_restores_previous_secrets() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn immediate_copy_revocation_clears_only_matching_content() -> TestResult {
|
||||||
|
let backend = MemoryClipboard::with_text(b"copied secret");
|
||||||
|
let mut manager = ClipboardManager::new(backend.clone(), ClipboardTimeout::default());
|
||||||
|
let secret = SecretBytes::new(b"copied secret".to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
manager.clear_current_copy(&secret)?,
|
||||||
|
ClipboardDisposition::Cleared
|
||||||
|
);
|
||||||
|
assert_eq!(backend.text(), None);
|
||||||
|
backend.set_text(b"new user value");
|
||||||
|
assert_eq!(
|
||||||
|
manager.clear_current_copy(&secret)?,
|
||||||
|
ClipboardDisposition::PreservedNewer
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
backend.text().as_deref(),
|
||||||
|
Some(b"new user value".as_slice())
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult {
|
fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ by Release shrinking. Certificate validation uses the operating system trust sto
|
|||||||
Local setup generates or imports a private GPG key, verifies its passphrase, creates
|
Local setup generates or imports a private GPG key, verifies its passphrase, creates
|
||||||
Git and the upstream `.gpg-id`, and commits the configuration. No server, camera,
|
Git and the upstream `.gpg-id`, and commits the configuration. No server, camera,
|
||||||
or biometric permission is needed. Encrypted prepared keys survive process death;
|
or biometric permission is needed. Encrypted prepared keys survive process death;
|
||||||
passphrases and tokens remain in memory only. Form secrets survive rotation through
|
passphrases and tokens remain in memory only and are excluded from saved-state
|
||||||
a ViewModel and are excluded from saved-state Bundles. After process death transport
|
Bundles. Presentation revocation clears form secrets synchronously, including
|
||||||
and passphrase inputs must be entered again.
|
backgrounding, rotation and memory pressure. Pending transport requests are cancelled
|
||||||
|
and released; transport and passphrase inputs must be entered again after returning.
|
||||||
|
|
||||||
HTTPS setup discovers branches with a temporary Rust repository, selects the branch,
|
HTTPS setup discovers branches with a temporary Rust repository, selects the branch,
|
||||||
and checks the selected private key against the cloned root recipient policy. Reusing
|
and checks the selected private key against the cloned root recipient policy. Reusing
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Android presentation security
|
||||||
|
|
||||||
|
The Android client presents Rust-owned authentication state. `crates/storage`
|
||||||
|
owns lease expiry, renewal, editor disposal, and clipboard content comparison.
|
||||||
|
Kotlin forwards OS events and applies native window, input, and clipboard controls.
|
||||||
|
|
||||||
|
## Lifecycle and immediate cleanup
|
||||||
|
|
||||||
|
| Event | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| Foreground/resume | Report foreground to Rust; remain locked until explicit unlock. |
|
||||||
|
| Explicit interaction | Report user activity while unlocked; Rust decides lease renewal. |
|
||||||
|
| Pause/stop/task switch | Immediately cover native content and clear protected presentation, then revoke the Rust lease. |
|
||||||
|
| Loss of top-resumed status | Revoke for an inactive multi-window activity, except while its own OS biometric request is pending. |
|
||||||
|
| Native window focus lost/user leaves task | Revoke even if Recents keeps the activity resumed; owned dialog/menu focus transfers remain usable. |
|
||||||
|
| Screen off/device locked | Cancel authentication and revoke the lease. |
|
||||||
|
| Trim memory/low memory | Clear protected presentation and revoke regardless of trim level. |
|
||||||
|
| Process recreation | Start locked; never restore a lease or plaintext from saved state. |
|
||||||
|
| Manual lock/expiry | Clear protected presentation, editors, OTP state, and active clipboard copies. |
|
||||||
|
|
||||||
|
`ProtectedPresentation` invokes main-thread cleanup synchronously. Its callbacks
|
||||||
|
clear setup secrets, cancel staged setup/remote operations, drop decrypted facade
|
||||||
|
sections and editor/TOTP state, clear local dialog inputs, and cover the old native
|
||||||
|
surface with an opaque overlay. Accessibility descendants are suppressed while
|
||||||
|
covered. A generation change discards the old composition; the cleaned content is
|
||||||
|
rendered before the resumed activity is uncovered. This keeps the frame clock
|
||||||
|
running while preventing a stale frame from being visible between cleanup and
|
||||||
|
recomposition. Rust revocation jobs run in order and complete before a subsequent
|
||||||
|
unlock; stale results cannot republish a protected document.
|
||||||
|
Owned dialogs and menus register their window before opening and report their own
|
||||||
|
focus loss. The activity excludes those internal focus transfers and its active
|
||||||
|
OS biometric request from external focus-loss revocation. The native cover stays
|
||||||
|
in place until the cleaned root is both resumed and focused again.
|
||||||
|
|
||||||
|
TOTP ticks, recomposition, polling, animations, clipboard waiting/cleanup, and
|
||||||
|
foreground notifications never report user activity. Rotation clears input and
|
||||||
|
cancels setup operations rather than saving passphrases, armor, or tokens.
|
||||||
|
|
||||||
|
## Windows, inputs, and diagnostics
|
||||||
|
|
||||||
|
The main activity sets `FLAG_SECURE` before Compose renders. Sensitive dialogs
|
||||||
|
use secure windows; other dialogs inherit the activity flag. API 33+ also disables
|
||||||
|
recent-task screenshots. Picture-in-picture is disabled in the manifest. An
|
||||||
|
activity launched on a secondary display is revoked and finished. Task switching
|
||||||
|
and inactive multi-window views clear protected presentation; returning does not
|
||||||
|
automatically authenticate. Screen sharing/casting to nonsecure surfaces uses the
|
||||||
|
same window policy. See Android's [secure-window contract](https://developer.android.com/reference/android/view/WindowManager.LayoutParams#FLAG_SECURE).
|
||||||
|
|
||||||
|
The activity and dialog roots exclude descendants from autofill and, on API 30+,
|
||||||
|
content capture. Input sessions request no personalized learning, fullscreen IME,
|
||||||
|
or extract UI. Password editors remain masked until an explicit reveal action.
|
||||||
|
Unlocked accessible fields remain available to assistive technology; locked
|
||||||
|
content is removed and covered. See the platform [view controls](https://developer.android.com/reference/android/view/View)
|
||||||
|
and [IME flags](https://developer.android.com/reference/android/view/inputmethod/EditorInfo).
|
||||||
|
|
||||||
|
The production Android modules have no secret logging, analytics, notification
|
||||||
|
payloads, or plaintext previews. Facade secret state and generated bridge records
|
||||||
|
have redacted diagnostic strings; errors use typed Rust messages or generic OS
|
||||||
|
failure text. Saved state contains navigation/setup choices, not protected input
|
||||||
|
or decrypted documents. Tests inspect serialized saved bundles and immediate
|
||||||
|
accessibility suppression. No memory-dump/export facility is provided.
|
||||||
|
|
||||||
|
The current manifest contains the main activity. Credential-provider components
|
||||||
|
are delivered by issue #109; they must use these protected presentation/window
|
||||||
|
controls when introduced. This issue does not install an empty credential service.
|
||||||
|
|
||||||
|
## Clipboard ownership and limits
|
||||||
|
|
||||||
|
Each explicit copy obtains a separate native clipboard capability. Rust supplies
|
||||||
|
the selected stored field and directs timeout/cancellation cleanup. Android marks
|
||||||
|
the clip sensitive, assigns a random owner identity, and records the OS timestamp.
|
||||||
|
Cleanup requires that identity, timestamp, and Rust's content comparison still
|
||||||
|
match. Another copy with identical text, unrelated user text, or a restored old
|
||||||
|
clip with a new timestamp is preserved. The adapter checks ownership again just
|
||||||
|
before clearing. A successful write publishes explicit copy feedback. Revocation
|
||||||
|
attempts cleanup synchronously while foreground clipboard access is still
|
||||||
|
available; no previous clipboard history is retained or restored.
|
||||||
|
|
||||||
|
Android's ClipboardManager does not offer an atomic compare-and-clear operation
|
||||||
|
across IPC calls. Another application can race the final check; this implementation
|
||||||
|
cannot promise atomicity that the OS API lacks. Android 10+ can deny background
|
||||||
|
clipboard reads, so cleanup cannot be guaranteed after the process has already
|
||||||
|
lost access or died. System/OEM clipboard histories may retain copies independently.
|
||||||
|
The sensitive flag suppresses supported clipboard previews; it is not encryption.
|
||||||
|
See [Android clipboard security](https://developer.android.com/privacy-and-security/risks/secure-clipboard-handling)
|
||||||
|
and [clipboard behavior](https://developer.android.com/develop/ui/views/touch-and-input/copy-paste).
|
||||||
|
|
||||||
|
`FLAG_SECURE`, autofill/content-capture exclusions, and IME requests rely on OS/OEM
|
||||||
|
and keyboard cooperation. They cannot protect against a rooted OS, debugger,
|
||||||
|
malicious keyboard, camera photographing the screen, or forensic memory access.
|
||||||
|
JVM/Compose strings cannot be reliably zeroized; cleanup drops references and
|
||||||
|
Rust zeroizes owned secret buffers, without claiming forensic JVM erasure.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Rust tests exercise lifecycle revocation/editor disposal, foreground without
|
||||||
|
renewal, and immediate content-checked clipboard cleanup. Native facade tests
|
||||||
|
use real encrypted entries and verify synchronous clearing for background,
|
||||||
|
device lock, memory pressure, and process recreation. UI/OS tests cover native
|
||||||
|
clipboard ownership and restored metadata, sensitive clips, secure windows,
|
||||||
|
IME flags, saved bundles, rotation, and immediate accessibility masking.
|
||||||
|
|
||||||
|
The emulator matrix covers ARM64 API 26 (4 KB pages), API 37 (4 KB), and API 37
|
||||||
|
(strict 16 KB), including app/core/bridge instrumentation and five cold starts.
|
||||||
|
The existing embedded API 37 emulator additionally exercises real Keystore
|
||||||
|
biometric enrollment, unlock, cancellation, background cancellation, foreground
|
||||||
|
remaining locked, and memory-pressure revocation using disposable public fixture
|
||||||
|
credentials and its existing synthetic fingerprint. Computer use checks task
|
||||||
|
switching/recents and cleared input on return. ARMv7 receives build/package
|
||||||
|
validation; no suitable ARMv7 emulator image is available. Physical-device and
|
||||||
|
OEM-specific capture/casting coverage are excluded from this milestone's test
|
||||||
|
scope. Accessibility assertions do not claim spoken TalkBack verification.
|
||||||
Reference in new issue
Block a user