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

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

No files matched your search

@@ -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")
@@ -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")
@@ -0,0 +1,224 @@
package de.rfc1437.ironstorage
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ComponentCallbacks2
import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.ViewModelProvider
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.features.ProtectedContent
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.awaitCancellation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class PresentationSecurityTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
val done = CountDownLatch(1)
lateinit var form: SetupFormViewModel
compose.runOnIdle {
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
form.passphrase = "public-task-switch-marker-100"
compose.activity.setProtectedTestContent {
MaterialTheme {
Column {
Text("Task switching security fixture")
OutlinedTextField(
form.passphrase,
{ form.passphrase = it },
label = { Text("Disposable protected input") }
)
if (form.passphrase.isEmpty()) Text("Protected value cleared")
TextButton(onClick = {
done.countDown()
}) { Text("Finish security inspection") }
}
}
}
}
val started = android.os.SystemClock.elapsedRealtime()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
}
compose.runOnIdle {
assertEquals("", form.passphrase)
assertFalse(
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
}
@Test fun inputSessionsRequestNoLearningAndNoKeyboardExtractWindow() {
val attributes = AtomicReference<EditorInfo?>()
compose.runOnIdle {
compose.activity.setContent {
InterceptPlatformTextInput(interceptor = { request, _ ->
val info = EditorInfo()
val connection = request.createInputConnection(info)
attributes.set(info)
try {
awaitCancellation()
} finally {
connection.closeConnection()
}
}) {
ProtectedContent(compose.activity.authentication.presentation) {
MaterialTheme {
OutlinedTextField("", {}, label = { Text("Public input") })
}
}
}
}
}
compose.onNode(hasSetTextAction()).performClick()
compose.waitUntil(10000) { attributes.get() != null }
val flags = attributes.get()!!.imeOptions
assertTrue(flags and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING != 0)
assertTrue(flags and EditorInfo.IME_FLAG_NO_FULLSCREEN != 0)
assertTrue(flags and EditorInfo.IME_FLAG_NO_EXTRACT_UI != 0)
}
@Test fun clipboardOwnershipPreservesIdenticalNewerCopiesAndUserContent() {
compose.runOnIdle {
val manager = compose.activity.getSystemService(ClipboardManager::class.java)
val first = AndroidSecretClipboard(compose.activity).beginCopy()
val second = AndroidSecretClipboard(compose.activity).beginCopy()
first.write("public-same-value".toByteArray())
second.write("public-same-value".toByteArray())
assertNull(first.read())
first.clear()
assertEquals("public-same-value", second.read()!!.toString(Charsets.UTF_8))
manager.setPrimaryClip(ClipData.newPlainText("User", "public-same-value"))
second.clear()
assertEquals("public-same-value", manager.primaryClip!!.getItemAt(0).text.toString())
assertNull(second.read())
first.write("public-restored-value".toByteArray())
val restored = ClipData(manager.primaryClip!!)
android.os.SystemClock.sleep(2)
manager.setPrimaryClip(restored)
assertNull(first.read())
first.clear()
assertEquals(
"public-restored-value",
manager.primaryClip!!.getItemAt(0).text.toString()
)
second.write("owned public value".toByteArray())
second.clear()
assertTrue(
manager.primaryClip == null ||
manager.primaryClip!!.getItemAt(0).text.isNullOrEmpty()
)
}
}
@Suppress("DEPRECATION")
@Test
fun memoryPressureMasksImmediatelyAndRecreationNeverSavesSetupSecrets() {
lateinit var form: SetupFormViewModel
compose.runOnIdle {
form = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
compose.activity.setProtectedTestContent {
MaterialTheme { Text("Public security fixture") }
}
}
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
fun populate() {
form.passphrase = "public-passphrase-marker-100"
form.armor = "public-armor-marker-100"
form.token = "public-token-marker-100"
}
fun assertCleared() {
assertEquals("", form.passphrase)
assertEquals("", form.armor)
assertEquals("", form.token)
}
compose.runOnIdle {
populate()
compose.activity.onWindowFocusChanged(false)
assertCleared()
compose.activity.onWindowFocusChanged(true)
}
compose.runOnIdle {
val window = compose.activity.window
populate()
(compose.activity.application as IronStorageApplication).onTrimMemory(
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW
)
assertCleared()
assertEquals(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
window.decorView.importantForAccessibility
)
assertTrue(
window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE !=
0
)
assertEquals(
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
window.decorView.importantForAutofill
)
if (Build.VERSION.SDK_INT >= 30) {
assertEquals(
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS,
window.decorView.importantForContentCapture
)
}
}
compose.onNodeWithText("Public security fixture").assertIsDisplayed()
compose.runOnIdle {
populate()
val saved = Bundle()
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
compose.activity,
saved
)
val parcel = Parcel.obtain()
try {
parcel.writeBundle(saved)
val bytes = parcel.marshall()
for (marker in listOf(form.passphrase, form.armor, form.token)) {
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
}
} finally {
parcel.recycle()
}
}
compose.activityRule.scenario.recreate()
compose.runOnIdle { assertCleared() }
}
}
@@ -0,0 +1,10 @@
package de.rfc1437.ironstorage
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import de.rfc1437.ironstorage.features.ProtectedContent
/** Keep the production surface and IME protections around substituted test screens. */
internal fun MainActivity.setProtectedTestContent(content: @Composable () -> Unit) {
setContent { ProtectedContent(authentication.presentation, content) }
}
+2 -1
View File
@@ -11,7 +11,8 @@
android:supportsRtl="true"
android: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)
}
}
}
}
@@ -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)
}
@@ -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()
@@ -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."
)
@@ -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)
}
}
@@ -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)
@@ -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>
@@ -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)
@@ -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"
}
}