Enforce Android lifecycle and presentation security (#100)
This commit is contained in:
1 parent
c0ba6d43d8
commit
a06f39af23
37 files changed
+1129
-110
No files matched your search
+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,11 +104,37 @@ class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
if (Build.VERSION.SDK_INT >= 33) setRecentsScreenshotEnabled(false)
|
||||
window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
window.decorView.importantForContentCapture =
|
||||
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
|
||||
}
|
||||
cleanup = authentication.presentation.register {
|
||||
setupForm.clearSecrets()
|
||||
onboarding.cancel()
|
||||
}
|
||||
val filter = IntentFilter(Intent.ACTION_SCREEN_OFF)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(protectedState, filter, RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(protectedState, filter)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val externalDisplay = windowManager.defaultDisplay.displayId != Display.DEFAULT_DISPLAY
|
||||
if (externalDisplay) {
|
||||
authentication.lifecycle(AuthenticationLifecycle.Background)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
keystore.attach(this)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
VariantContent(viewModel, repository, setupForm)
|
||||
ProtectedContent(authentication.presentation) {
|
||||
MaterialTheme {
|
||||
VariantContent(viewModel, repository, setupForm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
+48
-13
@@ -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,13 +84,15 @@ class NativeAuthenticationRepository(
|
||||
result
|
||||
}
|
||||
val value = withContext(io) { current!!.state() }
|
||||
if (ticket == sequence.get()) mutable.value = value.toFacade()
|
||||
if (ticket == sequence.get()) publish(value.toFacade())
|
||||
result
|
||||
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||
if (ticket == sequence.get()) {
|
||||
val value = withContext(io) { current?.state() }
|
||||
mutable.value = (value?.toFacade() ?: AuthenticationState()).copy(
|
||||
failure = error.toFacade()
|
||||
publish(
|
||||
(value?.toFacade() ?: AuthenticationState()).copy(
|
||||
failure = error.toFacade()
|
||||
)
|
||||
)
|
||||
}
|
||||
throw error
|
||||
@@ -122,19 +130,26 @@ class NativeAuthenticationRepository(
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun publish(value: AuthenticationState) {
|
||||
if (mutable.value.unlocked && !value.unlocked) presentation.clear()
|
||||
mutable.value = value
|
||||
}
|
||||
private fun unavailable() {
|
||||
mutable.value = AuthenticationState(
|
||||
failure = AuthenticationFailure(
|
||||
AuthenticationFailureKind.Configuration,
|
||||
"Authentication is unavailable",
|
||||
"Unlock the device or recover private storage and reload."
|
||||
publish(
|
||||
AuthenticationState(
|
||||
failure = AuthenticationFailure(
|
||||
AuthenticationFailureKind.Configuration,
|
||||
"Authentication is unavailable",
|
||||
"Unlock the device or recover private storage and reload."
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun lock() = revoke(false)
|
||||
override fun cancel() = revoke(true)
|
||||
private fun revoke(cancel: Boolean) {
|
||||
private fun revoke(cancel: Boolean, event: MobileLifecycleEvent? = null) {
|
||||
val ticket = sequence.incrementAndGet()
|
||||
presentation.clear()
|
||||
mutable.value = mutable.value.copy(
|
||||
unlocked = false,
|
||||
remainingSeconds = 0u,
|
||||
@@ -151,9 +166,15 @@ class NativeAuthenticationRepository(
|
||||
)
|
||||
session.cancelAuthentication()
|
||||
val authentication = current
|
||||
val previous = revocation
|
||||
revocation = scope.launch(io) {
|
||||
previous?.join()
|
||||
try {
|
||||
if (cancel) authentication?.cancel() else authentication?.manualLock()
|
||||
when {
|
||||
event != null -> authentication?.lifecycle(event)
|
||||
cancel -> authentication?.cancel()
|
||||
else -> authentication?.manualLock()
|
||||
}
|
||||
} catch (_: MobileAuthenticationFfiException) {
|
||||
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
||||
if (ticket == sequence.get()) unavailable()
|
||||
@@ -161,12 +182,26 @@ class NativeAuthenticationRepository(
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun lifecycle(event: AuthenticationLifecycle) {
|
||||
val native = when (event) {
|
||||
AuthenticationLifecycle.Foreground -> MobileLifecycleEvent.FOREGROUND
|
||||
AuthenticationLifecycle.Background -> MobileLifecycleEvent.BACKGROUND
|
||||
AuthenticationLifecycle.DeviceLocked -> MobileLifecycleEvent.DEVICE_LOCKED
|
||||
AuthenticationLifecycle.MemoryPressure -> MobileLifecycleEvent.MEMORY_PRESSURE
|
||||
AuthenticationLifecycle.ProcessRecreated -> MobileLifecycleEvent.PROCESS_RECREATED
|
||||
}
|
||||
if (event == AuthenticationLifecycle.Foreground) {
|
||||
run { it.lifecycle(native) }
|
||||
} else {
|
||||
revoke(false, native)
|
||||
}
|
||||
}
|
||||
override fun touch() {
|
||||
if (!mutable.value.unlocked || mutable.value.busy) return
|
||||
val ticket = sequence.get()
|
||||
scope.launch(io) {
|
||||
try {
|
||||
current?.touchUserActivity()
|
||||
current?.lifecycle(MobileLifecycleEvent.USER_ACTIVITY)
|
||||
} catch (_: MobileAuthenticationFfiException) {
|
||||
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
|
||||
if (ticket == sequence.get()) lock()
|
||||
|
||||
+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."
|
||||
)
|
||||
+74
-45
@@ -14,8 +14,10 @@ import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.clearMobileEntryCopy
|
||||
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -40,11 +42,15 @@ class NativePasswordRepository(
|
||||
private val sequence = AtomicLong()
|
||||
private val mutex = Mutex()
|
||||
init {
|
||||
scope.launch {
|
||||
authentication.state.collect { lease ->
|
||||
if (!lease.unlocked) mutable.value = mutable.value.cleared()
|
||||
val registration = authentication.presentation.register {
|
||||
if (mutable.value.sections != null) {
|
||||
sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.cleared().copy(loading = false)
|
||||
} else {
|
||||
mutable.value = mutable.value.cleared()
|
||||
}
|
||||
}
|
||||
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
@@ -53,7 +59,7 @@ class NativePasswordRepository(
|
||||
authentication.state.value.unlocked &&
|
||||
!authentication.state.value.busy
|
||||
) {
|
||||
content { current ->
|
||||
content(false) { current ->
|
||||
val detail = authentication.authenticated { auth ->
|
||||
auth.refreshEntryPresentation(
|
||||
requireNotNull(current.selected).path,
|
||||
@@ -231,59 +237,72 @@ class NativePasswordRepository(
|
||||
) = content { current ->
|
||||
val copy = authentication.authenticated { get(it, current) }
|
||||
val ticket = sequence.get()
|
||||
val owned = clipboard.beginCopy()
|
||||
val written = CompletableDeferred<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) {
|
||||
// A lock may have happened after Rust returned the copy value.
|
||||
if (!authentication.state.value.unlocked) {
|
||||
throw MobilePlatformFfiException.Cancelled()
|
||||
}
|
||||
clipboardAccess { owned.write(value) }
|
||||
written.complete(true)
|
||||
Unit
|
||||
}
|
||||
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
|
||||
clipboardAccess { owned.clear() }
|
||||
}
|
||||
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
while ((SystemClock.elapsedRealtime() - start).toULong() <
|
||||
milliseconds
|
||||
) {
|
||||
if (!authentication.state.value.unlocked) {
|
||||
return MobileClipboardWait.CANCELLED
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
return MobileClipboardWait.ELAPSED
|
||||
}
|
||||
}
|
||||
val registration = withContext(Dispatchers.Main.immediate) {
|
||||
authentication.presentation.register {
|
||||
try {
|
||||
clearMobileEntryCopy(copy, callback)
|
||||
} catch (_: MobilePlatformFfiException) {
|
||||
mutable.value = mutable.value.copy(error = "Clipboard cleanup is unavailable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch(io) {
|
||||
try {
|
||||
copyMobileEntryClearing(
|
||||
copy,
|
||||
object : MobileClipboard {
|
||||
override fun read(): MobileClipboardContent =
|
||||
runBlocking(Dispatchers.Main.immediate) {
|
||||
clipboard.read()?.let { MobileClipboardContent.Text(it) }
|
||||
?: MobileClipboardContent.EmptyOrNonText
|
||||
}
|
||||
override fun write(value: ByteArray) =
|
||||
runBlocking(Dispatchers.Main.immediate) {
|
||||
// A lock may have happened after Rust returned the copy value.
|
||||
if (!authentication.state.value.unlocked) {
|
||||
throw MobilePlatformFfiException.Cancelled()
|
||||
}
|
||||
clipboard.write(value)
|
||||
if (ticket == sequence.get()) {
|
||||
mutable.value = mutable.value.copy(
|
||||
notice = "Copied. The clipboard will be " +
|
||||
"cleared automatically."
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
|
||||
clipboard.clear()
|
||||
}
|
||||
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
while ((SystemClock.elapsedRealtime() - start).toULong() <
|
||||
milliseconds
|
||||
) {
|
||||
if (!authentication.state.value.unlocked) {
|
||||
return MobileClipboardWait.CANCELLED
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
return MobileClipboardWait.ELAPSED
|
||||
}
|
||||
}
|
||||
)
|
||||
copyMobileEntryClearing(copy, callback)
|
||||
} catch (_: MobilePlatformFfiException.Cancelled) {
|
||||
written.complete(false)
|
||||
// Rust cleans up before returning cancellation.
|
||||
} catch (_: MobilePlatformFfiException) {
|
||||
written.complete(false)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (ticket == sequence.get()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Copy is unavailable.", notice = null)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(kotlinx.coroutines.NonCancellable + Dispatchers.Main.immediate) {
|
||||
registration.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
current
|
||||
if (written.await()) {
|
||||
current.copy(notice = "Copied. The clipboard will be cleared automatically.")
|
||||
} else {
|
||||
current.copy(error = "Copy is unavailable.", notice = null)
|
||||
}
|
||||
}
|
||||
override fun prepareMutation(action: PasswordMutation) = content { current ->
|
||||
val plan = authentication.authenticated {
|
||||
@@ -326,7 +345,10 @@ class NativePasswordRepository(
|
||||
}
|
||||
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
|
||||
}
|
||||
private fun content(action: suspend (PasswordState) -> PasswordState) {
|
||||
private fun content(
|
||||
userActivity: Boolean = true,
|
||||
action: suspend (PasswordState) -> PasswordState
|
||||
) {
|
||||
val current = mutable.value
|
||||
if (current.sections == null || current.selected == null ||
|
||||
current.documentRevision == null || !authentication.state.value.unlocked ||
|
||||
@@ -334,6 +356,7 @@ class NativePasswordRepository(
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (userActivity) authentication.touch()
|
||||
run(false) { action(current) }
|
||||
}
|
||||
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
|
||||
@@ -435,3 +458,9 @@ private fun PasswordMutation.native() = when (this) {
|
||||
}
|
||||
|
||||
private fun PasswordState.entryPath() = requireNotNull(selected).path
|
||||
|
||||
private fun <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)
|
||||
|
||||
+38
-6
@@ -1,29 +1,61 @@
|
||||
package de.rfc1437.ironstorage.platform
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PersistableBundle
|
||||
import de.rfc1437.ironstorage.core.SecretClipboard
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
class AndroidSecretClipboard(context: Context) : SecretClipboard {
|
||||
class AndroidSecretClipboard(private val context: Context) : SecretClipboard {
|
||||
private val manager = context.getSystemService(ClipboardManager::class.java)
|
||||
override fun read(): ByteArray? = manager.primaryClip?.let { clip ->
|
||||
if (clip.itemCount == 1) clip.getItemAt(0).text?.toString()?.toByteArray() else null
|
||||
private val identity = UUID.randomUUID().toString()
|
||||
private var timestamp: Long? = null
|
||||
override fun beginCopy(): SecretClipboard = AndroidSecretClipboard(context)
|
||||
private fun owns(clip: ClipData) = clip.description.extras?.getString(OWNER) == identity &&
|
||||
timestamp != null && clip.description.timestamp == timestamp
|
||||
override fun read(): ByteArray? = access {
|
||||
manager.primaryClip?.takeIf(::owns)?.let { clip ->
|
||||
if (clip.itemCount == 1) clip.getItemAt(0).text?.toString()?.toByteArray() else null
|
||||
}
|
||||
}
|
||||
override fun write(value: ByteArray) {
|
||||
override fun write(value: ByteArray) = access {
|
||||
val clip = ClipData.newPlainText("IronStorage", value.toString(Charsets.UTF_8))
|
||||
clip.description.extras = PersistableBundle().apply {
|
||||
putBoolean("android.content.extra.IS_SENSITIVE", true)
|
||||
putBoolean(
|
||||
if (Build.VERSION.SDK_INT >=
|
||||
33
|
||||
) {
|
||||
ClipDescription.EXTRA_IS_SENSITIVE
|
||||
} else {
|
||||
"android.content.extra.IS_SENSITIVE"
|
||||
},
|
||||
true
|
||||
)
|
||||
putString(OWNER, identity)
|
||||
}
|
||||
manager.setPrimaryClip(clip)
|
||||
timestamp = manager.primaryClipDescription?.takeIf {
|
||||
it.extras?.getString(OWNER) == identity
|
||||
}?.timestamp
|
||||
}
|
||||
override fun clear() {
|
||||
override fun clear() = access {
|
||||
if (manager.primaryClip?.let(::owns) != true) return@access
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
manager.clearPrimaryClip()
|
||||
} else {
|
||||
manager.setPrimaryClip(ClipData.newPlainText("", ""))
|
||||
}
|
||||
}
|
||||
private fun <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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user