Enforce Android lifecycle and presentation security (#100)
This commit is contained in:
1 parent
c0ba6d43d8
commit
a06f39af23
37 files changed
+1101
-82
No files matched your search
@@ -81,6 +81,8 @@ and [`docs/android/parity.md`](docs/android/parity.md). Android implementation
|
||||
belongs to milestone 06; the decision record does not claim a shipped client.
|
||||
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
|
||||
|
||||
|
||||
+24
-4
@@ -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")
|
||||
|
||||
+17
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<MainActivity>()
|
||||
private fun show(repository: Fixture) {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setContent { MaterialTheme { HomeScreen(repository) } }
|
||||
compose.activity.setProtectedTestContent { MaterialTheme { HomeScreen(repository) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.features.ProtectedContent
|
||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class PresentationSecurityTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
|
||||
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
|
||||
val done = CountDownLatch(1)
|
||||
lateinit var form: SetupFormViewModel
|
||||
compose.runOnIdle {
|
||||
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
|
||||
form.passphrase = "public-task-switch-marker-100"
|
||||
compose.activity.setProtectedTestContent {
|
||||
MaterialTheme {
|
||||
Column {
|
||||
Text("Task switching security fixture")
|
||||
OutlinedTextField(
|
||||
form.passphrase,
|
||||
{ form.passphrase = it },
|
||||
label = { Text("Disposable protected input") }
|
||||
)
|
||||
if (form.passphrase.isEmpty()) Text("Protected value cleared")
|
||||
TextButton(onClick = {
|
||||
done.countDown()
|
||||
}) { Text("Finish security inspection") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val started = android.os.SystemClock.elapsedRealtime()
|
||||
while (!done.await(100, TimeUnit.MILLISECONDS)) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
compose.waitForIdle()
|
||||
assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
|
||||
}
|
||||
compose.runOnIdle {
|
||||
assertEquals("", form.passphrase)
|
||||
assertFalse(
|
||||
compose.activity.window.decorView.importantForAccessibility ==
|
||||
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun inputSessionsRequestNoLearningAndNoKeyboardExtractWindow() {
|
||||
val attributes = AtomicReference<EditorInfo?>()
|
||||
compose.runOnIdle {
|
||||
compose.activity.setContent {
|
||||
InterceptPlatformTextInput(interceptor = { request, _ ->
|
||||
val info = EditorInfo()
|
||||
val connection = request.createInputConnection(info)
|
||||
attributes.set(info)
|
||||
try {
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
connection.closeConnection()
|
||||
}
|
||||
}) {
|
||||
ProtectedContent(compose.activity.authentication.presentation) {
|
||||
MaterialTheme {
|
||||
OutlinedTextField("", {}, label = { Text("Public input") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNode(hasSetTextAction()).performClick()
|
||||
compose.waitUntil(10000) { attributes.get() != null }
|
||||
val flags = attributes.get()!!.imeOptions
|
||||
assertTrue(flags and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING != 0)
|
||||
assertTrue(flags and EditorInfo.IME_FLAG_NO_FULLSCREEN != 0)
|
||||
assertTrue(flags and EditorInfo.IME_FLAG_NO_EXTRACT_UI != 0)
|
||||
}
|
||||
|
||||
@Test fun clipboardOwnershipPreservesIdenticalNewerCopiesAndUserContent() {
|
||||
compose.runOnIdle {
|
||||
val manager = compose.activity.getSystemService(ClipboardManager::class.java)
|
||||
val first = AndroidSecretClipboard(compose.activity).beginCopy()
|
||||
val second = AndroidSecretClipboard(compose.activity).beginCopy()
|
||||
first.write("public-same-value".toByteArray())
|
||||
second.write("public-same-value".toByteArray())
|
||||
assertNull(first.read())
|
||||
first.clear()
|
||||
assertEquals("public-same-value", second.read()!!.toString(Charsets.UTF_8))
|
||||
manager.setPrimaryClip(ClipData.newPlainText("User", "public-same-value"))
|
||||
second.clear()
|
||||
assertEquals("public-same-value", manager.primaryClip!!.getItemAt(0).text.toString())
|
||||
assertNull(second.read())
|
||||
first.write("public-restored-value".toByteArray())
|
||||
val restored = ClipData(manager.primaryClip!!)
|
||||
android.os.SystemClock.sleep(2)
|
||||
manager.setPrimaryClip(restored)
|
||||
assertNull(first.read())
|
||||
first.clear()
|
||||
assertEquals(
|
||||
"public-restored-value",
|
||||
manager.primaryClip!!.getItemAt(0).text.toString()
|
||||
)
|
||||
second.write("owned public value".toByteArray())
|
||||
second.clear()
|
||||
assertTrue(
|
||||
manager.primaryClip == null ||
|
||||
manager.primaryClip!!.getItemAt(0).text.isNullOrEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun memoryPressureMasksImmediatelyAndRecreationNeverSavesSetupSecrets() {
|
||||
lateinit var form: SetupFormViewModel
|
||||
compose.runOnIdle {
|
||||
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
|
||||
compose.activity.setProtectedTestContent {
|
||||
MaterialTheme { Text("Public security fixture") }
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
|
||||
fun populate() {
|
||||
form.passphrase = "public-passphrase-marker-100"
|
||||
form.armor = "public-armor-marker-100"
|
||||
form.token = "public-token-marker-100"
|
||||
}
|
||||
fun assertCleared() {
|
||||
assertEquals("", form.passphrase)
|
||||
assertEquals("", form.armor)
|
||||
assertEquals("", form.token)
|
||||
}
|
||||
compose.runOnIdle {
|
||||
populate()
|
||||
compose.activity.onWindowFocusChanged(false)
|
||||
assertCleared()
|
||||
compose.activity.onWindowFocusChanged(true)
|
||||
}
|
||||
compose.runOnIdle {
|
||||
val window = compose.activity.window
|
||||
populate()
|
||||
(compose.activity.application as IronStorageApplication).onTrimMemory(
|
||||
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW
|
||||
)
|
||||
assertCleared()
|
||||
assertEquals(
|
||||
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
|
||||
window.decorView.importantForAccessibility
|
||||
)
|
||||
assertTrue(
|
||||
window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE !=
|
||||
0
|
||||
)
|
||||
assertEquals(
|
||||
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
|
||||
window.decorView.importantForAutofill
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
assertEquals(
|
||||
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS,
|
||||
window.decorView.importantForContentCapture
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
|
||||
compose.runOnIdle {
|
||||
populate()
|
||||
val saved = Bundle()
|
||||
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
|
||||
compose.activity,
|
||||
saved
|
||||
)
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
parcel.writeBundle(saved)
|
||||
val bytes = parcel.marshall()
|
||||
for (marker in listOf(form.passphrase, form.armor, form.token)) {
|
||||
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
|
||||
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
|
||||
}
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
compose.activityRule.scenario.recreate()
|
||||
compose.runOnIdle { assertCleared() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import de.rfc1437.ironstorage.features.ProtectedContent
|
||||
|
||||
/** Keep the production surface and IME protections around substituted test screens. */
|
||||
internal fun MainActivity.setProtectedTestContent(content: @Composable () -> Unit) {
|
||||
setContent { ProtectedContent(authentication.presentation, content) }
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.IronStorage"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
<activity android:name=".MainActivity" android:exported="true"
|
||||
android:supportsPictureInPicture="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,12 +104,38 @@ 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 {
|
||||
ProtectedContent(authentication.presentation) {
|
||||
MaterialTheme {
|
||||
VariantContent(viewModel, repository, setupForm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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) {
|
||||
|
||||
@@ -27,6 +27,7 @@ data class AuthenticationState(
|
||||
val failure: AuthenticationFailure? = null
|
||||
)
|
||||
interface AuthenticationRepository {
|
||||
val presentation: ProtectedPresentation
|
||||
val state: StateFlow<AuthenticationState>
|
||||
fun reload()
|
||||
fun unlock()
|
||||
@@ -35,4 +36,5 @@ interface AuthenticationRepository {
|
||||
fun lock()
|
||||
fun cancel()
|
||||
fun touch()
|
||||
fun lifecycle(event: AuthenticationLifecycle)
|
||||
}
|
||||
+43
-8
@@ -7,6 +7,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.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<AuthenticationState> = 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 <T> authenticated(block: (MobileAuthentication) -> T): T =
|
||||
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
||||
@@ -78,14 +84,16 @@ 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(
|
||||
publish(
|
||||
(value?.toFacade() ?: AuthenticationState()).copy(
|
||||
failure = error.toFacade()
|
||||
)
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} 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() {
|
||||
mutable.value = AuthenticationState(
|
||||
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()
|
||||
|
||||
+63
-7
@@ -8,6 +8,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingErrorKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileOnboardingFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.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<SetupStatus> = 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."
|
||||
)
|
||||
+54
-25
@@ -14,8 +14,10 @@ import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.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,32 +237,24 @@ class NativePasswordRepository(
|
||||
) = content { current ->
|
||||
val copy = authentication.authenticated { get(it, current) }
|
||||
val ticket = sequence.get()
|
||||
scope.launch(io) {
|
||||
try {
|
||||
copyMobileEntryClearing(
|
||||
copy,
|
||||
object : MobileClipboard {
|
||||
override fun read(): MobileClipboardContent =
|
||||
runBlocking(Dispatchers.Main.immediate) {
|
||||
clipboard.read()?.let { MobileClipboardContent.Text(it) }
|
||||
val owned = clipboard.beginCopy()
|
||||
val written = CompletableDeferred<Boolean>()
|
||||
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) {
|
||||
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."
|
||||
)
|
||||
}
|
||||
clipboardAccess { owned.write(value) }
|
||||
written.complete(true)
|
||||
Unit
|
||||
}
|
||||
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
|
||||
clipboard.clear()
|
||||
clipboardAccess { owned.clear() }
|
||||
}
|
||||
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
@@ -271,19 +269,40 @@ class NativePasswordRepository(
|
||||
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) {
|
||||
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 <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. */
|
||||
interface SecretClipboard {
|
||||
fun beginCopy(): SecretClipboard
|
||||
fun read(): ByteArray?
|
||||
fun write(value: ByteArray)
|
||||
fun clear()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProtectedPresentationTest {
|
||||
@Test fun tracksOwnedWindowsUntilEveryWindowCloses() {
|
||||
val presentation = ProtectedPresentation()
|
||||
val first = presentation.registerWindow()
|
||||
val second = presentation.registerWindow()
|
||||
first.close()
|
||||
assertTrue(presentation.hasOwnedWindow)
|
||||
second.close()
|
||||
assertFalse(presentation.hasOwnedWindow)
|
||||
}
|
||||
|
||||
@Test fun clearsSynchronouslyAndDropsDisposedRegistrations() {
|
||||
val presentation = ProtectedPresentation()
|
||||
var secret = "public-fixture"
|
||||
val registration = presentation.register { secret = "" }
|
||||
presentation.clear()
|
||||
assertEquals("", secret)
|
||||
assertEquals(1L, presentation.generation.value)
|
||||
registration.close()
|
||||
secret = "disposed"
|
||||
presentation.clear()
|
||||
assertEquals("disposed", secret)
|
||||
assertEquals(2L, presentation.generation.value)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -80,6 +80,7 @@ fun AuthenticationControls(repository: AuthenticationRepository) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.auth_enroll_title),
|
||||
stringResource(R.string.auth_enroll_detail),
|
||||
presentation = repository.presentation,
|
||||
onCancel = {
|
||||
enrollment = false
|
||||
repository.cancel()
|
||||
@@ -96,15 +97,17 @@ fun AuthenticationControls(repository: AuthenticationRepository) {
|
||||
internal fun PassphraseDialog(
|
||||
title: String,
|
||||
detail: String,
|
||||
presentation: de.rfc1437.ironstorage.core.ProtectedPresentation,
|
||||
onCancel: () -> Unit,
|
||||
onSubmit: (String) -> Unit
|
||||
) {
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var secret by rememberProtectedInput(presentation)
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
secret = ""
|
||||
onCancel()
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { secret = "" } }
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = {
|
||||
@@ -114,6 +117,7 @@ internal fun PassphraseDialog(
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProtectedInputSurface(presentation)
|
||||
Text(detail)
|
||||
OutlinedTextField(
|
||||
value = secret,
|
||||
|
||||
@@ -59,7 +59,9 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
||||
TextButton(enabled = !state.loading, onClick = { menu = true }) {
|
||||
Text(stringResource(R.string.entry_more))
|
||||
}
|
||||
if (menu) ProtectedWindow()
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
ProtectedInputSurface()
|
||||
PasswordMutation.entries.forEach { action ->
|
||||
DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = {
|
||||
menu = false
|
||||
@@ -70,10 +72,12 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
||||
})
|
||||
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
|
||||
if (edit) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
onDismissRequest = { edit = false },
|
||||
title = { Text(stringResource(R.string.entry_edit)) },
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
state.sections?.flatMap {
|
||||
it.fields
|
||||
@@ -195,9 +199,13 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
|
||||
fun EntryDialogs(
|
||||
state: PasswordState,
|
||||
repository: PasswordRepository,
|
||||
authentication: de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
) {
|
||||
state.edit?.let { edit ->
|
||||
var value by remember(edit) { mutableStateOf(edit.value) }
|
||||
var value by rememberProtectedInput(authentication.presentation, edit.value)
|
||||
var revealed by remember(edit) { mutableStateOf(false) }
|
||||
val systemClipboard = LocalClipboard.current
|
||||
val clipboard = remember(repository, edit.id, systemClipboard) {
|
||||
@@ -208,16 +216,19 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
onDismissRequest = repository::dismissEdit,
|
||||
title = { Text(edit.label) },
|
||||
text = {
|
||||
CompositionLocalProvider(LocalClipboard provides clipboard) {
|
||||
Column {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
OutlinedTextField(
|
||||
value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
authentication.touch()
|
||||
},
|
||||
label = { Text(edit.label) },
|
||||
singleLine = !edit.multiline,
|
||||
@@ -245,7 +256,10 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
|
||||
)
|
||||
Text(stringResource(R.string.entry_copy_stored_hint))
|
||||
if (edit.sensitive) {
|
||||
TextButton(onClick = { revealed = !revealed }) {
|
||||
TextButton(onClick = {
|
||||
revealed = !revealed
|
||||
authentication.touch()
|
||||
}) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (revealed) R.string.entry_hide else R.string.entry_reveal
|
||||
@@ -273,10 +287,12 @@ fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
|
||||
var destination by remember(plan) { mutableStateOf<String?>(null) }
|
||||
var overwrite by remember(plan, destination) { mutableStateOf(false) }
|
||||
val selected = plan.destinations.find { it.path == destination }
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
onDismissRequest = repository::dismissMutation,
|
||||
title = { Text(stringResource(plan.action.label())) },
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(plan.source)
|
||||
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
|
||||
|
||||
@@ -203,6 +203,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
if (committing) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
committing = false
|
||||
@@ -210,6 +211,7 @@ fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
|
||||
},
|
||||
title = { Text(stringResource(R.string.home_commit_title)) },
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column {
|
||||
Text(state.name)
|
||||
Text(state.email)
|
||||
|
||||
+2
-1
@@ -37,7 +37,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Secrets survive configuration changes in memory, never in a saved-state Bundle. */
|
||||
/** Setup inputs stay in memory and are synchronously cleared on presentation revocation. */
|
||||
@HiltViewModel
|
||||
class SetupFormViewModel @Inject constructor() : ViewModel() {
|
||||
var passphrase by mutableStateOf("")
|
||||
@@ -56,6 +56,7 @@ fun OnboardingScreen(
|
||||
secrets: SetupFormViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ProtectedInputSurface()
|
||||
val state by repository.setup.collectAsStateWithLifecycle()
|
||||
var step by rememberSaveable { mutableStateOf("choice") }
|
||||
var clone by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -57,6 +58,11 @@ fun PasswordScreen(
|
||||
authentication: AuthenticationRepository,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
DisposableEffect(repository, authentication) {
|
||||
onDispose {
|
||||
if (authentication.state.value.unlocked) repository.lock()
|
||||
}
|
||||
}
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
var actions by remember { mutableStateOf(false) }
|
||||
@@ -122,6 +128,12 @@ fun PasswordScreen(
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
lease.failure?.let {
|
||||
Text(
|
||||
it.title + "\n" + it.detail,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
if (state.stale) Text(stringResource(R.string.password_changed))
|
||||
}
|
||||
}
|
||||
@@ -260,7 +272,11 @@ fun PasswordScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lease.unlocked && state.sections != null) EntryDialogs(state, repository)
|
||||
if (lease.unlocked &&
|
||||
state.sections != null
|
||||
) {
|
||||
EntryDialogs(state, repository, authentication)
|
||||
}
|
||||
if (manual && entry != null) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.auth_manual),
|
||||
@@ -271,6 +287,7 @@ fun PasswordScreen(
|
||||
R.string.auth_manual_detail
|
||||
}
|
||||
),
|
||||
presentation = authentication.presentation,
|
||||
onCancel = {
|
||||
manual = false
|
||||
authentication.cancel()
|
||||
@@ -284,12 +301,16 @@ fun PasswordScreen(
|
||||
}
|
||||
if (actions && state.selected != null && !state.loading) {
|
||||
val selected = requireNotNull(state.selected)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
onDismissRequest = { actions = false },
|
||||
title = {
|
||||
Text(selected.title)
|
||||
},
|
||||
text = { Text(selected.detail + "\n" + selected.path) },
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Text(selected.detail + "\n" + selected.path)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
actions = false
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import de.rfc1437.ironstorage.core.ProtectedPresentation
|
||||
|
||||
private val LocalProtectedPresentation = staticCompositionLocalOf<ProtectedPresentation?> { null }
|
||||
private val LocalProtectedRoot = staticCompositionLocalOf<View?> { null }
|
||||
|
||||
/** Register before showing a dialog/popup so its focus transfer does not lock the activity. */
|
||||
@Composable
|
||||
internal fun ProtectedWindow() {
|
||||
val presentation = LocalProtectedPresentation.current
|
||||
DisposableEffect(presentation) {
|
||||
val registration = presentation?.registerWindow()
|
||||
onDispose { registration?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide the old native surface before clearing and rebuilding its presentation. */
|
||||
@Composable
|
||||
fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable () -> Unit) {
|
||||
val view = LocalView.current.rootView
|
||||
val surface = remember(view) { protectedSurface(view) }
|
||||
val focused = remember(view) { mutableStateOf(view.hasWindowFocus()) }
|
||||
val generation by presentation.generation.collectAsState()
|
||||
val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||
DisposableEffect(presentation, view) {
|
||||
val registration = presentation.register(surface::cover)
|
||||
val focus = ViewTreeObserver.OnWindowFocusChangeListener { focused.value = it }
|
||||
view.viewTreeObserver.addOnWindowFocusChangeListener(focus)
|
||||
onDispose {
|
||||
registration.close()
|
||||
if (view.viewTreeObserver.isAlive) {
|
||||
view.viewTreeObserver.removeOnWindowFocusChangeListener(focus)
|
||||
}
|
||||
}
|
||||
}
|
||||
InterceptPlatformTextInput(interceptor = { request, next ->
|
||||
next.startInputMethod(object : PlatformTextInputMethodRequest {
|
||||
override fun createInputConnection(outAttributes: EditorInfo): InputConnection {
|
||||
val connection = request.createInputConnection(outAttributes)
|
||||
outAttributes.imeOptions = outAttributes.imeOptions or
|
||||
EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING or
|
||||
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||
return connection
|
||||
}
|
||||
})
|
||||
}) {
|
||||
CompositionLocalProvider(
|
||||
LocalProtectedPresentation provides presentation,
|
||||
LocalProtectedRoot provides view
|
||||
) {
|
||||
key(generation) { content() }
|
||||
}
|
||||
}
|
||||
val revealCleanContent = focused.value && lifecycle.isAtLeast(Lifecycle.State.RESUMED)
|
||||
SideEffect {
|
||||
if (revealCleanContent) {
|
||||
surface.uncover()
|
||||
} else {
|
||||
surface.cover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberProtectedInput(
|
||||
presentation: ProtectedPresentation,
|
||||
initial: String = ""
|
||||
): MutableState<String> {
|
||||
val value = remember(presentation) { mutableStateOf(initial) }
|
||||
DisposableEffect(presentation) {
|
||||
val registration = presentation.register { value.value = "" }
|
||||
onDispose {
|
||||
registration.close()
|
||||
value.value = ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ProtectedInputSurface(
|
||||
presentation: ProtectedPresentation? = LocalProtectedPresentation.current
|
||||
) {
|
||||
val view = LocalView.current.rootView
|
||||
val root = LocalProtectedRoot.current
|
||||
val surface = remember(view) { protectedSurface(view) }
|
||||
DisposableEffect(view, presentation) {
|
||||
val registration = presentation?.register(surface::cover)
|
||||
val focus = ViewTreeObserver.OnWindowFocusChangeListener {
|
||||
if (!it) presentation?.onWindowFocusLost?.invoke()
|
||||
}
|
||||
if (view != root) view.viewTreeObserver.addOnWindowFocusChangeListener(focus)
|
||||
view.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.importantForContentCapture =
|
||||
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
|
||||
}
|
||||
onDispose {
|
||||
registration?.close()
|
||||
if (view != root && view.viewTreeObserver.isAlive) {
|
||||
view.viewTreeObserver.removeOnWindowFocusChangeListener(focus)
|
||||
}
|
||||
}
|
||||
}
|
||||
key(presentation?.generation?.collectAsState()?.value) {
|
||||
SideEffect { surface.uncover() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun protectedSurface(view: View): ProtectedSurface =
|
||||
(view.getTag(R.id.protected_surface) as? ProtectedSurface)
|
||||
?: ProtectedSurface(view as ViewGroup).also { view.setTag(R.id.protected_surface, it) }
|
||||
|
||||
private class ProtectedSurface(private val view: ViewGroup) {
|
||||
private val cover = View(view.context).apply { setBackgroundColor(Color.BLACK) }
|
||||
private val accessibility = view.importantForAccessibility
|
||||
private var covered = false
|
||||
fun cover() {
|
||||
view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
||||
cover.layout(0, 0, view.width, view.height)
|
||||
if (!covered) view.overlay.add(cover)
|
||||
covered = true
|
||||
}
|
||||
fun uncover() {
|
||||
view.overlay.remove(cover)
|
||||
view.importantForAccessibility = accessibility
|
||||
covered = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<item type="id" name="protected_surface" />
|
||||
</resources>
|
||||
+1
@@ -39,6 +39,7 @@ class AndroidKeystoreAdapter(context: Context) : AndroidKeystore {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var activity = WeakReference<FragmentActivity>(null)
|
||||
private val pending = AtomicReference<Pending?>()
|
||||
val authenticationPending: Boolean get() = pending.get() != null
|
||||
|
||||
fun attach(host: FragmentActivity) {
|
||||
activity = WeakReference(host)
|
||||
|
||||
+37
-5
@@ -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 ->
|
||||
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 <T> access(block: () -> T): T = try {
|
||||
block()
|
||||
} catch (_: RuntimeException) {
|
||||
throw IOException("Android clipboard access is unavailable.")
|
||||
}
|
||||
private companion object {
|
||||
const val OWNER = "de.rfc1437.ironstorage.clipboard-owner"
|
||||
}
|
||||
}
|
||||
@@ -282,6 +282,8 @@ pub enum MobileLifecycleEvent {
|
||||
Foreground,
|
||||
Background,
|
||||
DeviceLocked,
|
||||
MemoryPressure,
|
||||
ProcessRecreated,
|
||||
UserActivity,
|
||||
Cancel,
|
||||
}
|
||||
@@ -291,6 +293,8 @@ impl From<MobileLifecycleEvent> 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<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(
|
||||
copy: MobileEntryCopy,
|
||||
clipboard: Arc<dyn MobileClipboard>,
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -127,6 +127,8 @@ pub enum MobileLifecycleEvent {
|
||||
Foreground,
|
||||
Background,
|
||||
DeviceLocked,
|
||||
MemoryPressure,
|
||||
ProcessRecreated,
|
||||
UserActivity,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
@@ -177,6 +177,14 @@ impl<B: ClipboardBackend> ClipboardManager<B> {
|
||||
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(
|
||||
&mut self,
|
||||
copied: &SecretBytes,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Android presentation security
|
||||
|
||||
The Android client presents Rust-owned authentication state. `crates/storage`
|
||||
owns lease expiry, renewal, editor disposal, and clipboard content comparison.
|
||||
Kotlin forwards OS events and applies native window, input, and clipboard controls.
|
||||
|
||||
## Lifecycle and immediate cleanup
|
||||
|
||||
| Event | Behavior |
|
||||
| --- | --- |
|
||||
| Foreground/resume | Report foreground to Rust; remain locked until explicit unlock. |
|
||||
| Explicit interaction | Report user activity while unlocked; Rust decides lease renewal. |
|
||||
| Pause/stop/task switch | Immediately cover native content and clear protected presentation, then revoke the Rust lease. |
|
||||
| Loss of top-resumed status | Revoke for an inactive multi-window activity, except while its own OS biometric request is pending. |
|
||||
| Native window focus lost/user leaves task | Revoke even if Recents keeps the activity resumed; owned dialog/menu focus transfers remain usable. |
|
||||
| Screen off/device locked | Cancel authentication and revoke the lease. |
|
||||
| Trim memory/low memory | Clear protected presentation and revoke regardless of trim level. |
|
||||
| Process recreation | Start locked; never restore a lease or plaintext from saved state. |
|
||||
| Manual lock/expiry | Clear protected presentation, editors, OTP state, and active clipboard copies. |
|
||||
|
||||
`ProtectedPresentation` invokes main-thread cleanup synchronously. Its callbacks
|
||||
clear setup secrets, cancel staged setup/remote operations, drop decrypted facade
|
||||
sections and editor/TOTP state, clear local dialog inputs, and cover the old native
|
||||
surface with an opaque overlay. Accessibility descendants are suppressed while
|
||||
covered. A generation change discards the old composition; the cleaned content is
|
||||
rendered before the resumed activity is uncovered. This keeps the frame clock
|
||||
running while preventing a stale frame from being visible between cleanup and
|
||||
recomposition. Rust revocation jobs run in order and complete before a subsequent
|
||||
unlock; stale results cannot republish a protected document.
|
||||
Owned dialogs and menus register their window before opening and report their own
|
||||
focus loss. The activity excludes those internal focus transfers and its active
|
||||
OS biometric request from external focus-loss revocation. The native cover stays
|
||||
in place until the cleaned root is both resumed and focused again.
|
||||
|
||||
TOTP ticks, recomposition, polling, animations, clipboard waiting/cleanup, and
|
||||
foreground notifications never report user activity. Rotation clears input and
|
||||
cancels setup operations rather than saving passphrases, armor, or tokens.
|
||||
|
||||
## Windows, inputs, and diagnostics
|
||||
|
||||
The main activity sets `FLAG_SECURE` before Compose renders. Sensitive dialogs
|
||||
use secure windows; other dialogs inherit the activity flag. API 33+ also disables
|
||||
recent-task screenshots. Picture-in-picture is disabled in the manifest. An
|
||||
activity launched on a secondary display is revoked and finished. Task switching
|
||||
and inactive multi-window views clear protected presentation; returning does not
|
||||
automatically authenticate. Screen sharing/casting to nonsecure surfaces uses the
|
||||
same window policy. See Android's [secure-window contract](https://developer.android.com/reference/android/view/WindowManager.LayoutParams#FLAG_SECURE).
|
||||
|
||||
The activity and dialog roots exclude descendants from autofill and, on API 30+,
|
||||
content capture. Input sessions request no personalized learning, fullscreen IME,
|
||||
or extract UI. Password editors remain masked until an explicit reveal action.
|
||||
Unlocked accessible fields remain available to assistive technology; locked
|
||||
content is removed and covered. See the platform [view controls](https://developer.android.com/reference/android/view/View)
|
||||
and [IME flags](https://developer.android.com/reference/android/view/inputmethod/EditorInfo).
|
||||
|
||||
The production Android modules have no secret logging, analytics, notification
|
||||
payloads, or plaintext previews. Facade secret state and generated bridge records
|
||||
have redacted diagnostic strings; errors use typed Rust messages or generic OS
|
||||
failure text. Saved state contains navigation/setup choices, not protected input
|
||||
or decrypted documents. Tests inspect serialized saved bundles and immediate
|
||||
accessibility suppression. No memory-dump/export facility is provided.
|
||||
|
||||
The current manifest contains the main activity. Credential-provider components
|
||||
are delivered by issue #109; they must use these protected presentation/window
|
||||
controls when introduced. This issue does not install an empty credential service.
|
||||
|
||||
## Clipboard ownership and limits
|
||||
|
||||
Each explicit copy obtains a separate native clipboard capability. Rust supplies
|
||||
the selected stored field and directs timeout/cancellation cleanup. Android marks
|
||||
the clip sensitive, assigns a random owner identity, and records the OS timestamp.
|
||||
Cleanup requires that identity, timestamp, and Rust's content comparison still
|
||||
match. Another copy with identical text, unrelated user text, or a restored old
|
||||
clip with a new timestamp is preserved. The adapter checks ownership again just
|
||||
before clearing. A successful write publishes explicit copy feedback. Revocation
|
||||
attempts cleanup synchronously while foreground clipboard access is still
|
||||
available; no previous clipboard history is retained or restored.
|
||||
|
||||
Android's ClipboardManager does not offer an atomic compare-and-clear operation
|
||||
across IPC calls. Another application can race the final check; this implementation
|
||||
cannot promise atomicity that the OS API lacks. Android 10+ can deny background
|
||||
clipboard reads, so cleanup cannot be guaranteed after the process has already
|
||||
lost access or died. System/OEM clipboard histories may retain copies independently.
|
||||
The sensitive flag suppresses supported clipboard previews; it is not encryption.
|
||||
See [Android clipboard security](https://developer.android.com/privacy-and-security/risks/secure-clipboard-handling)
|
||||
and [clipboard behavior](https://developer.android.com/develop/ui/views/touch-and-input/copy-paste).
|
||||
|
||||
`FLAG_SECURE`, autofill/content-capture exclusions, and IME requests rely on OS/OEM
|
||||
and keyboard cooperation. They cannot protect against a rooted OS, debugger,
|
||||
malicious keyboard, camera photographing the screen, or forensic memory access.
|
||||
JVM/Compose strings cannot be reliably zeroized; cleanup drops references and
|
||||
Rust zeroizes owned secret buffers, without claiming forensic JVM erasure.
|
||||
|
||||
## Validation
|
||||
|
||||
Rust tests exercise lifecycle revocation/editor disposal, foreground without
|
||||
renewal, and immediate content-checked clipboard cleanup. Native facade tests
|
||||
use real encrypted entries and verify synchronous clearing for background,
|
||||
device lock, memory pressure, and process recreation. UI/OS tests cover native
|
||||
clipboard ownership and restored metadata, sensitive clips, secure windows,
|
||||
IME flags, saved bundles, rotation, and immediate accessibility masking.
|
||||
|
||||
The emulator matrix covers ARM64 API 26 (4 KB pages), API 37 (4 KB), and API 37
|
||||
(strict 16 KB), including app/core/bridge instrumentation and five cold starts.
|
||||
The existing embedded API 37 emulator additionally exercises real Keystore
|
||||
biometric enrollment, unlock, cancellation, background cancellation, foreground
|
||||
remaining locked, and memory-pressure revocation using disposable public fixture
|
||||
credentials and its existing synthetic fingerprint. Computer use checks task
|
||||
switching/recents and cleared input on return. ARMv7 receives build/package
|
||||
validation; no suitable ARMv7 emulator image is available. Physical-device and
|
||||
OEM-specific capture/casting coverage are excluded from this milestone's test
|
||||
scope. Accessibility assertions do not claim spoken TalkBack verification.
|
||||
Reference in new issue
Block a user