Enforce Android lifecycle and presentation security (#100)
Android ARM verification / rust (push) Failing after 47m6s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 18:59:18 +02:00
1 parent c0ba6d43d8
commit a06f39af23
37 files changed
+1101 -82

No files matched your search

+2
View File
@@ -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
@@ -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")
@@ -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")
@@ -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) }
}
+2 -1
View File
@@ -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)
} }
} }
} }
} }
}
@@ -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)
} }
@@ -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()
@@ -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."
)
@@ -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)
}
}
@@ -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)
@@ -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>
@@ -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)
@@ -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"
}
} }
+19
View File
@@ -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>,
+4 -3
View File
@@ -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()?;
+2
View File
@@ -127,6 +127,8 @@ pub enum MobileLifecycleEvent {
Foreground, Foreground,
Background, Background,
DeviceLocked, DeviceLocked,
MemoryPressure,
ProcessRecreated,
UserActivity, UserActivity,
Cancel, Cancel,
} }
+8
View File
@@ -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!(
+22
View File
@@ -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!(
+4 -3
View File
@@ -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
+112
View File
@@ -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.