From a06f39af23bc46df6c79d1ad53b531235c1b452c Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 9 Oct 2026 18:59:18 +0200 Subject: [PATCH] Enforce Android lifecycle and presentation security (#100) --- README.md | 2 + .../ironstorage/AuthenticationLeaseTest.kt | 28 ++- .../ironstorage/AuthenticationScreenTest.kt | 21 +- .../de/rfc1437/ironstorage/EntryViewerTest.kt | 15 +- .../de/rfc1437/ironstorage/HomeScreenTest.kt | 3 +- .../ironstorage/OnboardingScreenTest.kt | 5 +- .../rfc1437/ironstorage/PasswordScreenTest.kt | 13 +- .../ironstorage/PresentationSecurityTest.kt | 224 ++++++++++++++++++ .../ironstorage/ProtectedTestContent.kt | 10 + android/app/src/main/AndroidManifest.xml | 3 +- .../ironstorage/IronStorageApplication.kt | 23 +- .../de/rfc1437/ironstorage/MainActivity.kt | 92 ++++++- .../ironstorage/core/NativeEntryViewerTest.kt | 23 ++ .../core/AuthenticationRepository.kt | 2 + .../core/NativeAuthenticationRepository.kt | 61 ++++- .../core/NativeOnboardingRepository.kt | 70 +++++- .../core/NativePasswordRepository.kt | 119 ++++++---- .../ironstorage/core/ProtectedPresentation.kt | 34 +++ .../ironstorage/core/SecretClipboard.kt | 1 + .../core/ProtectedPresentationTest.kt | 32 +++ .../features/AuthenticationControls.kt | 6 +- .../ironstorage/features/EntryViewer.kt | 22 +- .../ironstorage/features/HomeScreen.kt | 2 + .../ironstorage/features/OnboardingScreen.kt | 3 +- .../ironstorage/features/PasswordScreen.kt | 25 +- .../ironstorage/features/ProtectedInput.kt | 153 ++++++++++++ android/features/src/main/res/values/ids.xml | 3 + .../platform/AndroidKeystoreAdapter.kt | 1 + .../platform/AndroidSecretClipboard.kt | 44 +++- crates/mobile-bridge/src/platform.rs | 19 ++ crates/storage/src/mobile_authentication.rs | 7 +- crates/storage/src/mobile_platform.rs | 2 + crates/storage/src/presentation.rs | 8 + crates/storage/tests/authentication_leases.rs | 22 ++ crates/storage/tests/presentation.rs | 22 ++ docs/android/onboarding.md | 7 +- docs/android/presentation-security.md | 112 +++++++++ 37 files changed, 1129 insertions(+), 110 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PresentationSecurityTest.kt create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt create mode 100644 android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt create mode 100644 android/core/src/test/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentationTest.kt create mode 100644 android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ProtectedInput.kt create mode 100644 android/features/src/main/res/values/ids.xml create mode 100644 docs/android/presentation-security.md diff --git a/README.md b/README.md index 8bd74b1..e07fbbf 100644 --- a/README.md +++ b/README.md @@ -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. The structured Android viewer and its validation are documented in [`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 diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationLeaseTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationLeaseTest.kt index b1d708f..4237554 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationLeaseTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationLeaseTest.kt @@ -1,6 +1,5 @@ package de.rfc1437.ironstorage -import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -11,6 +10,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.platform.app.InstrumentationRegistry import de.rfc1437.ironstorage.core.AndroidKeystore import de.rfc1437.ironstorage.core.AuthenticationFailureKind +import de.rfc1437.ironstorage.core.AuthenticationLifecycle import de.rfc1437.ironstorage.core.KeystoreAuthorization import de.rfc1437.ironstorage.core.KeystoreCiphertext import de.rfc1437.ironstorage.core.KeystoreKeySpecification @@ -47,7 +47,12 @@ class AuthenticationLeaseTest { private val context = instrumentation.targetContext @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) assertTrue( "Enroll a synthetic strong fingerprint for this opt-in test", @@ -98,7 +103,7 @@ class AuthenticationLeaseTest { scenario.onActivity { host = it os.attach(it) - it.setContent { + it.setProtectedTestContent { MaterialTheme { Column { Text(stage.collectAsState().value) @@ -140,9 +145,24 @@ class AuthenticationLeaseTest { request { authentication.unlock() } instrumentation.waitForIdleSync() scenario.moveToState(Lifecycle.State.CREATED) - request { authentication.lock() } + request { authentication.lifecycle(AuthenticationLifecycle.Background) } assertFalse(settled().unlocked) 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() } assertFalse(settled().biometricEnabled) show("98:reenroll") diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationScreenTest.kt index cc672ac..2b3cb99 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationScreenTest.kt @@ -1,6 +1,5 @@ package de.rfc1437.ironstorage -import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotEnabled @@ -26,7 +25,9 @@ class AuthenticationScreenTest { @Test fun enrollmentRequiresExplicitSubmissionAndTypedSuccessBeforeDisplayingEnabled() { val repository = Fixture() compose.activity.runOnUiThread { - compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } } + compose.activity.setProtectedTestContent { + MaterialTheme { AuthenticationControls(repository) } + } } compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed() compose.onNodeWithText("Enable biometric unlock").performClick() @@ -59,7 +60,9 @@ class AuthenticationScreenTest { @Test fun cancellationBackgroundAndRemountNeverRestoreThePassphrase() { val repository = Fixture() fun mount() = compose.activity.runOnUiThread { - compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } } + compose.activity.setProtectedTestContent { + MaterialTheme { AuthenticationControls(repository) } + } } mount() compose.onNodeWithText("Enable biometric unlock").performClick() @@ -73,7 +76,9 @@ class AuthenticationScreenTest { compose.onNodeWithText("GPG passphrase").assertDoesNotExist() compose.onNodeWithText("Enable biometric unlock").performClick() compose.onNodeWithText("Continue").assertIsNotEnabled() - compose.activity.runOnUiThread { compose.activity.setContent { MaterialTheme { } } } + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { MaterialTheme { } } + } mount() compose.onNodeWithText("Enable biometric unlock").performClick() compose.onNodeWithText("Continue").assertIsNotEnabled() @@ -82,6 +87,14 @@ class AuthenticationScreenTest { } 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()) var submitted: String? = null var locked = false diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt index 4461f9a..766d90d 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt @@ -2,7 +2,6 @@ package de.rfc1437.ironstorage import android.content.ClipboardManager import android.view.WindowManager -import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme @@ -58,7 +57,7 @@ class EntryViewerTest { @Test fun maskedFieldsExplicitActionsEditorDiagnosticsAndRevocation() { val repository = Fixture() compose.activity.runOnUiThread { - compose.activity.setContent { + compose.activity.setProtectedTestContent { MaterialTheme { PasswordScreen(repository, repository.authentication) } } } @@ -101,7 +100,7 @@ class EntryViewerTest { val repository = Fixture() repository.unlock("") compose.activity.runOnUiThread { - compose.activity.setContent { + compose.activity.setProtectedTestContent { MaterialTheme { PasswordScreen(repository, repository.authentication) } } } @@ -137,7 +136,7 @@ class EntryViewerTest { val repository = Fixture() val done = CountDownLatch(1) compose.activity.runOnUiThread { - compose.activity.setContent { + compose.activity.setProtectedTestContent { val density = LocalDensity.current CompositionLocalProvider( LocalDensity provides Density( @@ -184,6 +183,14 @@ class EntryViewerTest { MutableStateFlow(PasswordState(rows = listOf(row), selected = row, revision = "r")) val lease = MutableStateFlow(AuthenticationState()) 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 fun reload() = Unit override fun unlock() = Unit diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt index 0b0b581..5f46a41 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/HomeScreenTest.kt @@ -1,6 +1,5 @@ package de.rfc1437.ironstorage -import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotEnabled @@ -40,7 +39,7 @@ class HomeScreenTest { @get:Rule val compose = createAndroidComposeRule() private fun show(repository: Fixture) { compose.activity.runOnUiThread { - compose.activity.setContent { MaterialTheme { HomeScreen(repository) } } + compose.activity.setProtectedTestContent { MaterialTheme { HomeScreen(repository) } } } } diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt index fcb0c9c..fbd0861 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt @@ -1,6 +1,5 @@ package de.rfc1437.ironstorage -import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -26,7 +25,9 @@ class OnboardingScreenTest { val repository = FixtureRepository() val secrets = SetupFormViewModel() compose.activity.runOnUiThread { - compose.activity.setContent { MaterialTheme { OnboardingScreen(repository, secrets) } } + compose.activity.setProtectedTestContent { + MaterialTheme { OnboardingScreen(repository, secrets) } + } } compose.waitForIdle() compose.onNodeWithText("Create local store").performClick() diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt index c8aaa37..4fdc4b0 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt @@ -2,7 +2,6 @@ package de.rfc1437.ironstorage import android.content.pm.ActivityInfo import android.content.res.Configuration -import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.input.InputMode @@ -56,7 +55,7 @@ class PasswordScreenTest { val repository = Fixture() lateinit var inputMode: InputModeManager compose.activity.runOnUiThread { - compose.activity.setContent { + compose.activity.setProtectedTestContent { inputMode = LocalInputModeManager.current MaterialTheme { PasswordScreen(repository, repository.authentication) } } @@ -125,7 +124,7 @@ class PasswordScreenTest { repository.activate("folder-id", "root") repository.activate("entry-id", "folder") compose.activity.runOnUiThread { - compose.activity.setContent { + compose.activity.setProtectedTestContent { val density = LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) { MaterialTheme { PasswordScreen(repository, repository.authentication) } @@ -164,6 +163,14 @@ class PasswordScreenTest { MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder))) val lease = MutableStateFlow(AuthenticationState()) 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 fun reload() = Unit override fun unlock() = error("Unexpected direct unlock") diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PresentationSecurityTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PresentationSecurityTest.kt new file mode 100644 index 0000000..b7cf05d --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PresentationSecurityTest.kt @@ -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() + + @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() + 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() } + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt new file mode 100644 index 0000000..a0e0f25 --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ProtectedTestContent.kt @@ -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) } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 01407c8..8922196 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ android:supportsRtl="true" android:theme="@style/Theme.IronStorage" android:usesCleartextTraffic="false"> - + diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt index 4cf8853..49e2913 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/IronStorageApplication.kt @@ -2,6 +2,27 @@ package de.rfc1437.ironstorage import android.app.Application import dagger.hilt.android.HiltAndroidApp +import de.rfc1437.ironstorage.core.AuthenticationLifecycle +import de.rfc1437.ironstorage.core.AuthenticationRepository +import javax.inject.Inject @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() + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt index 040b133..328a7de 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt @@ -1,6 +1,14 @@ 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.view.Display +import android.view.View import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -8,9 +16,11 @@ import androidx.activity.viewModels import androidx.compose.material3.MaterialTheme import androidx.fragment.app.FragmentActivity import dagger.hilt.android.AndroidEntryPoint +import de.rfc1437.ironstorage.core.AuthenticationLifecycle 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.features.ProtectedContent import de.rfc1437.ironstorage.features.SetupFormViewModel import de.rfc1437.ironstorage.features.ShellViewModel import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter @@ -24,23 +34,69 @@ class MainActivity : FragmentActivity() { @Inject lateinit var authentication: AuthenticationRepository - @Inject lateinit var passwords: PasswordRepository + @Inject lateinit var onboarding: OnboardingRepository private val setupForm: SetupFormViewModel 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() { - passwords.lock() + authentication.lifecycle(AuthenticationLifecycle.Background) 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() { authentication.touch() super.onUserInteraction() } override fun onDestroy() { + cleanup?.close() + unregisterReceiver(protectedState) keystore.detach(this) super.onDestroy() } @@ -48,11 +104,37 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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) enableEdgeToEdge() setContent { - MaterialTheme { - VariantContent(viewModel, repository, setupForm) + ProtectedContent(authentication.presentation) { + MaterialTheme { + VariantContent(viewModel, repository, setupForm) + } } } } diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt index cab3904..3abdfd5 100644 --- a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt @@ -113,6 +113,27 @@ class NativeEntryViewerTest { assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline }) assertNotNull(repository.state.value.totp) 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" } request { repository.reveal(pin.id) } assertEquals("123493", fields().single { it.id == pin.id }.value) @@ -172,6 +193,8 @@ class NativeEntryViewerTest { } } private class MemoryClipboard : SecretClipboard { + override fun beginCopy(): SecretClipboard = this + @Volatile var value: ByteArray? = null override fun read() = value?.copyOf() override fun write(value: ByteArray) { diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AuthenticationRepository.kt index 1055c3a..0c7208e 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AuthenticationRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AuthenticationRepository.kt @@ -27,6 +27,7 @@ data class AuthenticationState( val failure: AuthenticationFailure? = null ) interface AuthenticationRepository { + val presentation: ProtectedPresentation val state: StateFlow fun reload() fun unlock() @@ -35,4 +36,5 @@ interface AuthenticationRepository { fun lock() fun cancel() fun touch() + fun lifecycle(event: AuthenticationLifecycle) } diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt index a08c9de..2d86ce4 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt @@ -7,6 +7,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationState import de.rfc1437.ironstorage.bridge.generated.MobileClock +import de.rfc1437.ironstorage.bridge.generated.MobileLifecycleEvent import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CoroutineDispatcher @@ -26,6 +27,7 @@ class NativeAuthenticationRepository( private val scope: CoroutineScope, private val io: CoroutineDispatcher ) : AuthenticationRepository { + override val presentation = ProtectedPresentation() private val mutable = MutableStateFlow(AuthenticationState()) override val state: StateFlow = mutable private val sequence = AtomicLong() @@ -37,6 +39,7 @@ class NativeAuthenticationRepository( override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong() } init { + presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) } scope.launch { while (true) { delay(250) @@ -45,7 +48,7 @@ class NativeAuthenticationRepository( try { val value = withContext(io) { current?.state() } if (ticket == sequence.get() && value != null) { - mutable.value = value.toFacade() + publish(value.toFacade()) } } catch (_: MobileAuthenticationFfiException) { if (ticket == sequence.get()) lock() @@ -57,7 +60,10 @@ class NativeAuthenticationRepository( } } 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 authenticated(block: (MobileAuthentication) -> T): T = withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { @@ -78,13 +84,15 @@ class NativeAuthenticationRepository( result } val value = withContext(io) { current!!.state() } - if (ticket == sequence.get()) mutable.value = value.toFacade() + if (ticket == sequence.get()) publish(value.toFacade()) result } catch (error: MobileAuthenticationFfiException.Failed) { if (ticket == sequence.get()) { val value = withContext(io) { current?.state() } - mutable.value = (value?.toFacade() ?: AuthenticationState()).copy( - failure = error.toFacade() + publish( + (value?.toFacade() ?: AuthenticationState()).copy( + failure = error.toFacade() + ) ) } throw error @@ -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() { - mutable.value = AuthenticationState( - failure = AuthenticationFailure( - AuthenticationFailureKind.Configuration, - "Authentication is unavailable", - "Unlock the device or recover private storage and reload." + publish( + AuthenticationState( + failure = AuthenticationFailure( + AuthenticationFailureKind.Configuration, + "Authentication is unavailable", + "Unlock the device or recover private storage and reload." + ) ) ) } override fun lock() = revoke(false) override fun cancel() = revoke(true) - private fun revoke(cancel: Boolean) { + private fun revoke(cancel: Boolean, event: MobileLifecycleEvent? = null) { val ticket = sequence.incrementAndGet() + presentation.clear() mutable.value = mutable.value.copy( unlocked = false, remainingSeconds = 0u, @@ -151,9 +166,15 @@ class NativeAuthenticationRepository( ) session.cancelAuthentication() val authentication = current + val previous = revocation revocation = scope.launch(io) { + previous?.join() try { - if (cancel) authentication?.cancel() else authentication?.manualLock() + when { + event != null -> authentication?.lifecycle(event) + cancel -> authentication?.cancel() + else -> authentication?.manualLock() + } } catch (_: MobileAuthenticationFfiException) { withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { 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() { if (!mutable.value.unlocked || mutable.value.busy) return val ticket = sequence.get() scope.launch(io) { try { - current?.touchUserActivity() + current?.lifecycle(MobileLifecycleEvent.USER_ACTIVITY) } catch (_: MobileAuthenticationFfiException) { withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { if (ticket == sequence.get()) lock() diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeOnboardingRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeOnboardingRepository.kt index 89f0254..0367446 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeOnboardingRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeOnboardingRepository.kt @@ -8,6 +8,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingOutcome import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -26,7 +27,9 @@ class NativeOnboardingRepository( override val setup: StateFlow = mutableSetup @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 @@ -46,7 +49,13 @@ class NativeOnboardingRepository( private fun newOperation(): MobileAndroidOnboarding { remote?.close() 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 { @@ -61,7 +70,15 @@ class NativeOnboardingRepository( override fun discover(server: String, account: String, repository: String, token: String) = runSetup { 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() pollProgress = false mutableSetup.update { @@ -82,7 +99,7 @@ class NativeOnboardingRepository( useExisting: Boolean ) = runSetup { completed( - requireNotNull(remote).cloneStore( + (remote ?: throw interrupted()).cloneStore( branch, fingerprint, passphrase.ifEmpty { @@ -108,8 +125,22 @@ class NativeOnboardingRepository( } } override fun cancel() { - if (!mutableSetup.value.busy) return - scope.launch(Dispatchers.IO) { operation?.cancel() } + revoked.set(true) + 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) { updateStatus(requireNotNull(operation).status()) @@ -136,13 +167,18 @@ class NativeOnboardingRepository( } private fun runSetup(action: () -> Unit) { if (mutableSetup.value.busy) return + revoked.set(false) pollProgress = true mutableSetup.update { it.copy(busy = true, failure = false) } scope.launch { val progress = launch { while (mutableSetup.value.busy) { 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 -> mutableSetup.update { if (pollProgress) { @@ -198,11 +234,31 @@ class NativeOnboardingRepository( 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 { pollProgress = false progress.cancel() + if (revoked.get()) { + withContext(Dispatchers.IO) { + remote?.close() + remote = null + operation?.close() + operation = null + } + } mutableSetup.update { it.copy(busy = false) } } } } } + +private fun interrupted() = MobileOnboardingFfiException.Failed( + MobileOnboardingErrorKind.INTERRUPTED, + "Setup interrupted", + "Return to setup and enter its secrets again." +) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt index 9500cb8..7135c92 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt @@ -14,8 +14,10 @@ import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import de.rfc1437.ironstorage.bridge.generated.clearMobileEntryCopy import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,11 +42,15 @@ class NativePasswordRepository( private val sequence = AtomicLong() private val mutex = Mutex() init { - scope.launch { - authentication.state.collect { lease -> - if (!lease.unlocked) mutable.value = mutable.value.cleared() + val registration = authentication.presentation.register { + if (mutable.value.sections != null) { + 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 { while (true) { delay(1000) @@ -53,7 +59,7 @@ class NativePasswordRepository( authentication.state.value.unlocked && !authentication.state.value.busy ) { - content { current -> + content(false) { current -> val detail = authentication.authenticated { auth -> auth.refreshEntryPresentation( requireNotNull(current.selected).path, @@ -231,59 +237,72 @@ class NativePasswordRepository( ) = content { current -> val copy = authentication.authenticated { get(it, current) } val ticket = sequence.get() + val owned = clipboard.beginCopy() + val written = CompletableDeferred() + val callback = object : MobileClipboard { + override fun read(): MobileClipboardContent = runBlocking(Dispatchers.Main.immediate) { + clipboardAccess { owned.read() }?.let { MobileClipboardContent.Text(it) } + ?: MobileClipboardContent.EmptyOrNonText + } + override fun write(value: ByteArray) = runBlocking(Dispatchers.Main.immediate) { + // A lock may have happened after Rust returned the copy value. + if (!authentication.state.value.unlocked) { + throw MobilePlatformFfiException.Cancelled() + } + clipboardAccess { owned.write(value) } + written.complete(true) + Unit + } + override fun clear() = runBlocking(Dispatchers.Main.immediate) { + clipboardAccess { owned.clear() } + } + override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait { + val start = SystemClock.elapsedRealtime() + while ((SystemClock.elapsedRealtime() - start).toULong() < + milliseconds + ) { + if (!authentication.state.value.unlocked) { + return MobileClipboardWait.CANCELLED + } + Thread.sleep(50) + } + 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, - object : MobileClipboard { - override fun read(): MobileClipboardContent = - runBlocking(Dispatchers.Main.immediate) { - clipboard.read()?.let { MobileClipboardContent.Text(it) } - ?: MobileClipboardContent.EmptyOrNonText - } - override fun write(value: ByteArray) = - runBlocking(Dispatchers.Main.immediate) { - // A lock may have happened after Rust returned the copy value. - if (!authentication.state.value.unlocked) { - throw MobilePlatformFfiException.Cancelled() - } - clipboard.write(value) - if (ticket == sequence.get()) { - mutable.value = mutable.value.copy( - notice = "Copied. The clipboard will be " + - "cleared automatically." - ) - } - } - override fun clear() = runBlocking(Dispatchers.Main.immediate) { - clipboard.clear() - } - override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait { - val start = SystemClock.elapsedRealtime() - while ((SystemClock.elapsedRealtime() - start).toULong() < - milliseconds - ) { - if (!authentication.state.value.unlocked) { - return MobileClipboardWait.CANCELLED - } - Thread.sleep(50) - } - return MobileClipboardWait.ELAPSED - } - } - ) + copyMobileEntryClearing(copy, callback) } catch (_: MobilePlatformFfiException.Cancelled) { + written.complete(false) // Rust cleans up before returning cancellation. } catch (_: MobilePlatformFfiException) { + written.complete(false) withContext(Dispatchers.Main.immediate) { if (ticket == sequence.get()) { mutable.value = 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 -> val plan = authentication.authenticated { @@ -326,7 +345,10 @@ class NativePasswordRepository( } 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 if (current.sections == null || current.selected == null || current.documentRevision == null || !authentication.state.value.unlocked || @@ -334,6 +356,7 @@ class NativePasswordRepository( ) { return } + if (userActivity) authentication.touch() run(false) { action(current) } } 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 clipboardAccess(action: () -> T): T = try { + action() +} catch (_: java.io.IOException) { + throw MobilePlatformFfiException.ClipboardFailed() +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt new file mode 100644 index 0000000..989efd6 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentation.kt @@ -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 = 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 +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SecretClipboard.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SecretClipboard.kt index 0cbe976..079a776 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SecretClipboard.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SecretClipboard.kt @@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core /** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */ interface SecretClipboard { + fun beginCopy(): SecretClipboard fun read(): ByteArray? fun write(value: ByteArray) fun clear() diff --git a/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentationTest.kt b/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentationTest.kt new file mode 100644 index 0000000..4d09ca2 --- /dev/null +++ b/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/ProtectedPresentationTest.kt @@ -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) + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/AuthenticationControls.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/AuthenticationControls.kt index db816bb..3b5494b 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/AuthenticationControls.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/AuthenticationControls.kt @@ -80,6 +80,7 @@ fun AuthenticationControls(repository: AuthenticationRepository) { PassphraseDialog( stringResource(R.string.auth_enroll_title), stringResource(R.string.auth_enroll_detail), + presentation = repository.presentation, onCancel = { enrollment = false repository.cancel() @@ -96,15 +97,17 @@ fun AuthenticationControls(repository: AuthenticationRepository) { internal fun PassphraseDialog( title: String, detail: String, + presentation: de.rfc1437.ironstorage.core.ProtectedPresentation, onCancel: () -> Unit, onSubmit: (String) -> Unit ) { - var secret by remember { mutableStateOf("") } + var secret by rememberProtectedInput(presentation) LifecycleEventEffect(Lifecycle.Event.ON_STOP) { secret = "" onCancel() } DisposableEffect(Unit) { onDispose { secret = "" } } + ProtectedWindow() AlertDialog( properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), onDismissRequest = { @@ -114,6 +117,7 @@ internal fun PassphraseDialog( title = { Text(title) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ProtectedInputSurface(presentation) Text(detail) OutlinedTextField( value = secret, diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt index 30f8739..ea3390c 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt @@ -59,7 +59,9 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito TextButton(enabled = !state.loading, onClick = { menu = true }) { Text(stringResource(R.string.entry_more)) } + if (menu) ProtectedWindow() DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + ProtectedInputSurface() PasswordMutation.entries.forEach { action -> DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = { menu = false @@ -70,10 +72,12 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito }) TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) } if (edit) { + ProtectedWindow() AlertDialog( onDismissRequest = { edit = false }, title = { Text(stringResource(R.string.entry_edit)) }, text = { + ProtectedInputSurface() Column(Modifier.verticalScroll(rememberScrollState())) { state.sections?.flatMap { it.fields @@ -195,9 +199,13 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito } @Composable -fun EntryDialogs(state: PasswordState, repository: PasswordRepository) { +fun EntryDialogs( + state: PasswordState, + repository: PasswordRepository, + authentication: de.rfc1437.ironstorage.core.AuthenticationRepository +) { state.edit?.let { edit -> - var value by remember(edit) { mutableStateOf(edit.value) } + var value by rememberProtectedInput(authentication.presentation, edit.value) var revealed by remember(edit) { mutableStateOf(false) } val systemClipboard = LocalClipboard.current val clipboard = remember(repository, edit.id, systemClipboard) { @@ -208,16 +216,19 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) { } } } + ProtectedWindow() AlertDialog( onDismissRequest = repository::dismissEdit, title = { Text(edit.label) }, text = { CompositionLocalProvider(LocalClipboard provides clipboard) { Column { + ProtectedInputSurface(authentication.presentation) OutlinedTextField( value, onValueChange = { value = it + authentication.touch() }, label = { Text(edit.label) }, singleLine = !edit.multiline, @@ -245,7 +256,10 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) { ) Text(stringResource(R.string.entry_copy_stored_hint)) if (edit.sensitive) { - TextButton(onClick = { revealed = !revealed }) { + TextButton(onClick = { + revealed = !revealed + authentication.touch() + }) { Text( stringResource( if (revealed) R.string.entry_hide else R.string.entry_reveal @@ -273,10 +287,12 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) { var destination by remember(plan) { mutableStateOf(null) } var overwrite by remember(plan, destination) { mutableStateOf(false) } val selected = plan.destinations.find { it.path == destination } + ProtectedWindow() AlertDialog( onDismissRequest = repository::dismissMutation, title = { Text(stringResource(plan.action.label())) }, text = { + ProtectedInputSurface() Column(Modifier.verticalScroll(rememberScrollState())) { Text(plan.source) if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor)) diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt index 4d0c497..13a059a 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/HomeScreen.kt @@ -203,6 +203,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) { } } if (committing) { + ProtectedWindow() AlertDialog( onDismissRequest = { committing = false @@ -210,6 +211,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) { }, title = { Text(stringResource(R.string.home_commit_title)) }, text = { + ProtectedInputSurface() Column { Text(state.name) Text(state.email) diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt index f4c5d3d..1d7acc2 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt @@ -37,7 +37,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.rfc1437.ironstorage.core.OnboardingRepository import javax.inject.Inject -/** Secrets survive configuration changes in memory, never in a saved-state Bundle. */ +/** Setup inputs stay in memory and are synchronously cleared on presentation revocation. */ @HiltViewModel class SetupFormViewModel @Inject constructor() : ViewModel() { var passphrase by mutableStateOf("") @@ -56,6 +56,7 @@ fun OnboardingScreen( secrets: SetupFormViewModel, modifier: Modifier = Modifier ) { + ProtectedInputSurface() val state by repository.setup.collectAsStateWithLifecycle() var step by rememberSaveable { mutableStateOf("choice") } var clone by rememberSaveable { mutableStateOf(false) } diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt index 2ed8c05..dca2b8a 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -57,6 +58,11 @@ fun PasswordScreen( authentication: AuthenticationRepository, modifier: Modifier = Modifier ) { + DisposableEffect(repository, authentication) { + onDispose { + if (authentication.state.value.unlocked) repository.lock() + } + } val state by repository.state.collectAsStateWithLifecycle() val navigator = rememberListDetailPaneScaffoldNavigator() var actions by remember { mutableStateOf(false) } @@ -122,6 +128,12 @@ fun PasswordScreen( state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + lease.failure?.let { + Text( + it.title + "\n" + it.detail, + color = MaterialTheme.colorScheme.error + ) + } if (state.stale) Text(stringResource(R.string.password_changed)) } } @@ -260,7 +272,11 @@ fun PasswordScreen( } } } - if (lease.unlocked && state.sections != null) EntryDialogs(state, repository) + if (lease.unlocked && + state.sections != null + ) { + EntryDialogs(state, repository, authentication) + } if (manual && entry != null) { PassphraseDialog( stringResource(R.string.auth_manual), @@ -271,6 +287,7 @@ fun PasswordScreen( R.string.auth_manual_detail } ), + presentation = authentication.presentation, onCancel = { manual = false authentication.cancel() @@ -284,12 +301,16 @@ fun PasswordScreen( } if (actions && state.selected != null && !state.loading) { val selected = requireNotNull(state.selected) + ProtectedWindow() AlertDialog( onDismissRequest = { actions = false }, title = { Text(selected.title) }, - text = { Text(selected.detail + "\n" + selected.path) }, + text = { + ProtectedInputSurface() + Text(selected.detail + "\n" + selected.path) + }, confirmButton = { TextButton(onClick = { actions = false diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ProtectedInput.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ProtectedInput.kt new file mode 100644 index 0000000..f45eba5 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ProtectedInput.kt @@ -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 { null } +private val LocalProtectedRoot = staticCompositionLocalOf { 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 { + 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 + } +} diff --git a/android/features/src/main/res/values/ids.xml b/android/features/src/main/res/values/ids.xml new file mode 100644 index 0000000..7270423 --- /dev/null +++ b/android/features/src/main/res/values/ids.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt index e75f2f5..1aa628d 100644 --- a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt +++ b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidKeystoreAdapter.kt @@ -39,6 +39,7 @@ class AndroidKeystoreAdapter(context: Context) : AndroidKeystore { private val handler = Handler(Looper.getMainLooper()) private var activity = WeakReference(null) private val pending = AtomicReference() + val authenticationPending: Boolean get() = pending.get() != null fun attach(host: FragmentActivity) { activity = WeakReference(host) diff --git a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSecretClipboard.kt b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSecretClipboard.kt index 83acde9..c53cd31 100644 --- a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSecretClipboard.kt +++ b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSecretClipboard.kt @@ -1,29 +1,61 @@ package de.rfc1437.ironstorage.platform import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.os.Build import android.os.PersistableBundle 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) - override fun read(): ByteArray? = manager.primaryClip?.let { clip -> - if (clip.itemCount == 1) clip.getItemAt(0).text?.toString()?.toByteArray() else null + 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 + } } - override fun write(value: ByteArray) { + override fun write(value: ByteArray) = access { val clip = ClipData.newPlainText("IronStorage", value.toString(Charsets.UTF_8)) 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) + 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) { manager.clearPrimaryClip() } else { manager.setPrimaryClip(ClipData.newPlainText("", "")) } } + private fun 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" + } } diff --git a/crates/mobile-bridge/src/platform.rs b/crates/mobile-bridge/src/platform.rs index eb8043e..43af423 100644 --- a/crates/mobile-bridge/src/platform.rs +++ b/crates/mobile-bridge/src/platform.rs @@ -282,6 +282,8 @@ pub enum MobileLifecycleEvent { Foreground, Background, DeviceLocked, + MemoryPressure, + ProcessRecreated, UserActivity, Cancel, } @@ -291,6 +293,8 @@ impl From for storage::MobileLifecycleEvent { MobileLifecycleEvent::Foreground => Self::Foreground, MobileLifecycleEvent::Background => Self::Background, MobileLifecycleEvent::DeviceLocked => Self::DeviceLocked, + MobileLifecycleEvent::MemoryPressure => Self::MemoryPressure, + MobileLifecycleEvent::ProcessRecreated => Self::ProcessRecreated, MobileLifecycleEvent::UserActivity => Self::UserActivity, MobileLifecycleEvent::Cancel => Self::Cancel, } @@ -404,6 +408,21 @@ pub fn copy_mobile_entry_clearing( 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, +) -> 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( copy: MobileEntryCopy, clipboard: Arc, diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 34d95dc..ace03e3 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -1609,9 +1609,10 @@ impl MobileAuthentication { use crate::mobile_platform::MobileLifecycleEvent; match event { MobileLifecycleEvent::Foreground => {} - MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => { - self.manual_lock()? - } + MobileLifecycleEvent::Background + | MobileLifecycleEvent::DeviceLocked + | MobileLifecycleEvent::MemoryPressure + | MobileLifecycleEvent::ProcessRecreated => self.manual_lock()?, MobileLifecycleEvent::UserActivity => { if self.state()?.unlocked() { self.touch_user_activity()?; diff --git a/crates/storage/src/mobile_platform.rs b/crates/storage/src/mobile_platform.rs index 702fcc8..d31ad92 100644 --- a/crates/storage/src/mobile_platform.rs +++ b/crates/storage/src/mobile_platform.rs @@ -127,6 +127,8 @@ pub enum MobileLifecycleEvent { Foreground, Background, DeviceLocked, + MemoryPressure, + ProcessRecreated, UserActivity, Cancel, } diff --git a/crates/storage/src/presentation.rs b/crates/storage/src/presentation.rs index 2c21d29..c5ec3ff 100644 --- a/crates/storage/src/presentation.rs +++ b/crates/storage/src/presentation.rs @@ -177,6 +177,14 @@ impl ClipboardManager { Ok(disposition) } + /// Clear only the current copy, without waiting or retaining previous data. + pub fn clear_current_copy( + &mut self, + copied: &SecretBytes, + ) -> Result { + self.cleanup(copied, &ClipboardContent::EmptyOrNonText) + } + fn cleanup( &mut self, copied: &SecretBytes, diff --git a/crates/storage/tests/authentication_leases.rs b/crates/storage/tests/authentication_leases.rs index ab64319..99287d1 100644 --- a/crates/storage/tests/authentication_leases.rs +++ b/crates/storage/tests/authentication_leases.rs @@ -85,6 +85,28 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co authentication.lifecycle(MobileLifecycleEvent::Background)?; assert!(!authentication.state()?.unlocked()); 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(); drop(authentication); assert!( diff --git a/crates/storage/tests/presentation.rs b/crates/storage/tests/presentation.rs index cec10ee..beda564 100644 --- a/crates/storage/tests/presentation.rs +++ b/crates/storage/tests/presentation.rs @@ -165,6 +165,28 @@ fn clearing_clipboard_never_reads_or_restores_previous_secrets() -> TestResult { 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] fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult { assert_eq!( diff --git a/docs/android/onboarding.md b/docs/android/onboarding.md index 1e887d4..43b6cca 100644 --- a/docs/android/onboarding.md +++ b/docs/android/onboarding.md @@ -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 Git and the upstream `.gpg-id`, and commits the configuration. No server, camera, or biometric permission is needed. Encrypted prepared keys survive process death; -passphrases and tokens remain in memory only. Form secrets survive rotation through -a ViewModel and are excluded from saved-state Bundles. After process death transport -and passphrase inputs must be entered again. +passphrases and tokens remain in memory only and are excluded from saved-state +Bundles. Presentation revocation clears form secrets synchronously, including +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, and checks the selected private key against the cloned root recipient policy. Reusing diff --git a/docs/android/presentation-security.md b/docs/android/presentation-security.md new file mode 100644 index 0000000..edc5e9e --- /dev/null +++ b/docs/android/presentation-security.md @@ -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.