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

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

No files matched your search

+2
View File
@@ -81,6 +81,8 @@ and [`docs/android/parity.md`](docs/android/parity.md). Android implementation
belongs to milestone 06; the decision record does not claim a shipped client.
The structured Android viewer and its validation are documented in
[`docs/android/entry-viewer.md`](docs/android/entry-viewer.md).
Lifecycle, capture, input, and clipboard protections and platform limits are
documented in [`docs/android/presentation-security.md`](docs/android/presentation-security.md).
## Project layout
@@ -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,12 +104,38 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (Build.VERSION.SDK_INT >= 33) setRecentsScreenshotEnabled(false)
window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS
if (Build.VERSION.SDK_INT >= 30) {
window.decorView.importantForContentCapture =
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
}
cleanup = authentication.presentation.register {
setupForm.clearSecrets()
onboarding.cancel()
}
val filter = IntentFilter(Intent.ACTION_SCREEN_OFF)
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(protectedState, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(protectedState, filter)
}
@Suppress("DEPRECATION")
val externalDisplay = windowManager.defaultDisplay.displayId != Display.DEFAULT_DISPLAY
if (externalDisplay) {
authentication.lifecycle(AuthenticationLifecycle.Background)
finish()
return
}
keystore.attach(this)
enableEdgeToEdge()
setContent {
ProtectedContent(authentication.presentation) {
MaterialTheme {
VariantContent(viewModel, repository, setupForm)
}
}
}
}
}
@@ -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,14 +84,16 @@ class NativeAuthenticationRepository(
result
}
val value = withContext(io) { current!!.state() }
if (ticket == sequence.get()) mutable.value = value.toFacade()
if (ticket == sequence.get()) publish(value.toFacade())
result
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == sequence.get()) {
val value = withContext(io) { current?.state() }
mutable.value = (value?.toFacade() ?: AuthenticationState()).copy(
publish(
(value?.toFacade() ?: AuthenticationState()).copy(
failure = error.toFacade()
)
)
}
throw error
} catch (error: MobilePrivateStorageFfiException) {
@@ -122,19 +130,26 @@ class NativeAuthenticationRepository(
}
}
}
private fun publish(value: AuthenticationState) {
if (mutable.value.unlocked && !value.unlocked) presentation.clear()
mutable.value = value
}
private fun unavailable() {
mutable.value = AuthenticationState(
publish(
AuthenticationState(
failure = AuthenticationFailure(
AuthenticationFailureKind.Configuration,
"Authentication is unavailable",
"Unlock the device or recover private storage and reload."
)
)
)
}
override fun lock() = revoke(false)
override fun cancel() = revoke(true)
private fun revoke(cancel: Boolean) {
private fun revoke(cancel: Boolean, event: MobileLifecycleEvent? = null) {
val ticket = sequence.incrementAndGet()
presentation.clear()
mutable.value = mutable.value.copy(
unlocked = false,
remainingSeconds = 0u,
@@ -151,9 +166,15 @@ class NativeAuthenticationRepository(
)
session.cancelAuthentication()
val authentication = current
val previous = revocation
revocation = scope.launch(io) {
previous?.join()
try {
if (cancel) authentication?.cancel() else authentication?.manualLock()
when {
event != null -> authentication?.lifecycle(event)
cancel -> authentication?.cancel()
else -> authentication?.manualLock()
}
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) unavailable()
@@ -161,12 +182,26 @@ class NativeAuthenticationRepository(
}
}
}
override fun lifecycle(event: AuthenticationLifecycle) {
val native = when (event) {
AuthenticationLifecycle.Foreground -> MobileLifecycleEvent.FOREGROUND
AuthenticationLifecycle.Background -> MobileLifecycleEvent.BACKGROUND
AuthenticationLifecycle.DeviceLocked -> MobileLifecycleEvent.DEVICE_LOCKED
AuthenticationLifecycle.MemoryPressure -> MobileLifecycleEvent.MEMORY_PRESSURE
AuthenticationLifecycle.ProcessRecreated -> MobileLifecycleEvent.PROCESS_RECREATED
}
if (event == AuthenticationLifecycle.Foreground) {
run { it.lifecycle(native) }
} else {
revoke(false, native)
}
}
override fun touch() {
if (!mutable.value.unlocked || mutable.value.busy) return
val ticket = sequence.get()
scope.launch(io) {
try {
current?.touchUserActivity()
current?.lifecycle(MobileLifecycleEvent.USER_ACTIVITY)
} catch (_: MobileAuthenticationFfiException) {
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (ticket == sequence.get()) lock()
@@ -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,32 +237,24 @@ class NativePasswordRepository(
) = content { current ->
val copy = authentication.authenticated { get(it, current) }
val ticket = sequence.get()
scope.launch(io) {
try {
copyMobileEntryClearing(
copy,
object : MobileClipboard {
override fun read(): MobileClipboardContent =
runBlocking(Dispatchers.Main.immediate) {
clipboard.read()?.let { MobileClipboardContent.Text(it) }
val owned = clipboard.beginCopy()
val written = CompletableDeferred<Boolean>()
val callback = object : MobileClipboard {
override fun read(): MobileClipboardContent = runBlocking(Dispatchers.Main.immediate) {
clipboardAccess { owned.read() }?.let { MobileClipboardContent.Text(it) }
?: MobileClipboardContent.EmptyOrNonText
}
override fun write(value: ByteArray) =
runBlocking(Dispatchers.Main.immediate) {
override fun write(value: ByteArray) = runBlocking(Dispatchers.Main.immediate) {
// A lock may have happened after Rust returned the copy value.
if (!authentication.state.value.unlocked) {
throw MobilePlatformFfiException.Cancelled()
}
clipboard.write(value)
if (ticket == sequence.get()) {
mutable.value = mutable.value.copy(
notice = "Copied. The clipboard will be " +
"cleared automatically."
)
}
clipboardAccess { owned.write(value) }
written.complete(true)
Unit
}
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
clipboard.clear()
clipboardAccess { owned.clear() }
}
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
val start = SystemClock.elapsedRealtime()
@@ -271,19 +269,40 @@ class NativePasswordRepository(
return MobileClipboardWait.ELAPSED
}
}
)
val registration = withContext(Dispatchers.Main.immediate) {
authentication.presentation.register {
try {
clearMobileEntryCopy(copy, callback)
} catch (_: MobilePlatformFfiException) {
mutable.value = mutable.value.copy(error = "Clipboard cleanup is unavailable.")
}
}
}
scope.launch(io) {
try {
copyMobileEntryClearing(copy, callback)
} catch (_: MobilePlatformFfiException.Cancelled) {
written.complete(false)
// Rust cleans up before returning cancellation.
} catch (_: MobilePlatformFfiException) {
written.complete(false)
withContext(Dispatchers.Main.immediate) {
if (ticket == sequence.get()) {
mutable.value =
mutable.value.copy(error = "Copy is unavailable.", notice = null)
}
}
} finally {
withContext(kotlinx.coroutines.NonCancellable + Dispatchers.Main.immediate) {
registration.close()
}
}
current
}
if (written.await()) {
current.copy(notice = "Copied. The clipboard will be cleared automatically.")
} else {
current.copy(error = "Copy is unavailable.", notice = null)
}
}
override fun prepareMutation(action: PasswordMutation) = content { current ->
val plan = authentication.authenticated {
@@ -326,7 +345,10 @@ class NativePasswordRepository(
}
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
}
private fun content(action: suspend (PasswordState) -> PasswordState) {
private fun content(
userActivity: Boolean = true,
action: suspend (PasswordState) -> PasswordState
) {
val current = mutable.value
if (current.sections == null || current.selected == null ||
current.documentRevision == null || !authentication.state.value.unlocked ||
@@ -334,6 +356,7 @@ class NativePasswordRepository(
) {
return
}
if (userActivity) authentication.touch()
run(false) { action(current) }
}
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
@@ -435,3 +458,9 @@ private fun PasswordMutation.native() = when (this) {
}
private fun PasswordState.entryPath() = requireNotNull(selected).path
private fun <T> clipboardAccess(action: () -> T): T = try {
action()
} catch (_: java.io.IOException) {
throw MobilePlatformFfiException.ClipboardFailed()
}
@@ -0,0 +1,34 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Main-thread presentation cleanup; Rust still owns authentication decisions. */
class ProtectedPresentation {
private var windows = 0
val hasOwnedWindow: Boolean get() = windows > 0
var onWindowFocusLost: () -> Unit = { clear() }
fun registerWindow(): AutoCloseable {
windows += 1
return AutoCloseable { windows -= 1 }
}
private val listeners = linkedSetOf<() -> Unit>()
private val mutableGeneration = MutableStateFlow(0L)
val generation: StateFlow<Long> = mutableGeneration
fun register(clear: () -> Unit): AutoCloseable {
synchronized(listeners) { listeners.add(clear) }
return AutoCloseable { synchronized(listeners) { listeners.remove(clear) } }
}
fun clear() {
synchronized(listeners) { listeners.toList() }.forEach { it() }
mutableGeneration.value += 1
}
}
enum class AuthenticationLifecycle {
Foreground,
Background,
DeviceLocked,
MemoryPressure,
ProcessRecreated
}
@@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core
/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */
interface SecretClipboard {
fun beginCopy(): SecretClipboard
fun read(): ByteArray?
fun write(value: ByteArray)
fun clear()
@@ -0,0 +1,32 @@
package de.rfc1437.ironstorage.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProtectedPresentationTest {
@Test fun tracksOwnedWindowsUntilEveryWindowCloses() {
val presentation = ProtectedPresentation()
val first = presentation.registerWindow()
val second = presentation.registerWindow()
first.close()
assertTrue(presentation.hasOwnedWindow)
second.close()
assertFalse(presentation.hasOwnedWindow)
}
@Test fun clearsSynchronouslyAndDropsDisposedRegistrations() {
val presentation = ProtectedPresentation()
var secret = "public-fixture"
val registration = presentation.register { secret = "" }
presentation.clear()
assertEquals("", secret)
assertEquals(1L, presentation.generation.value)
registration.close()
secret = "disposed"
presentation.clear()
assertEquals("disposed", secret)
assertEquals(2L, presentation.generation.value)
}
}
@@ -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 ->
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"
}
}
+19
View File
@@ -282,6 +282,8 @@ pub enum MobileLifecycleEvent {
Foreground,
Background,
DeviceLocked,
MemoryPressure,
ProcessRecreated,
UserActivity,
Cancel,
}
@@ -291,6 +293,8 @@ impl From<MobileLifecycleEvent> for storage::MobileLifecycleEvent {
MobileLifecycleEvent::Foreground => Self::Foreground,
MobileLifecycleEvent::Background => Self::Background,
MobileLifecycleEvent::DeviceLocked => Self::DeviceLocked,
MobileLifecycleEvent::MemoryPressure => Self::MemoryPressure,
MobileLifecycleEvent::ProcessRecreated => Self::ProcessRecreated,
MobileLifecycleEvent::UserActivity => Self::UserActivity,
MobileLifecycleEvent::Cancel => Self::Cancel,
}
@@ -404,6 +408,21 @@ pub fn copy_mobile_entry_clearing(
copy_entry(copy, clipboard, true)
}
/// Revoke a copy while the Android window still has foreground clipboard access.
#[uniffi::export]
pub fn clear_mobile_entry_copy(
copy: MobileEntryCopy,
clipboard: Arc<dyn MobileClipboard>,
) -> Result<(), MobilePlatformFfiError> {
presentation::ClipboardManager::new(
ClipboardAdapter(clipboard),
presentation::ClipboardTimeout::default(),
)
.clear_current_copy(&SecretBytes::new(copy.value.into_bytes()))
.map(|_| ())
.map_err(|_| MobilePlatformFfiError::ClipboardFailed)
}
fn copy_entry(
copy: MobileEntryCopy,
clipboard: Arc<dyn MobileClipboard>,
+4 -3
View File
@@ -1609,9 +1609,10 @@ impl MobileAuthentication {
use crate::mobile_platform::MobileLifecycleEvent;
match event {
MobileLifecycleEvent::Foreground => {}
MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => {
self.manual_lock()?
}
MobileLifecycleEvent::Background
| MobileLifecycleEvent::DeviceLocked
| MobileLifecycleEvent::MemoryPressure
| MobileLifecycleEvent::ProcessRecreated => self.manual_lock()?,
MobileLifecycleEvent::UserActivity => {
if self.state()?.unlocked() {
self.touch_user_activity()?;
+2
View File
@@ -127,6 +127,8 @@ pub enum MobileLifecycleEvent {
Foreground,
Background,
DeviceLocked,
MemoryPressure,
ProcessRecreated,
UserActivity,
Cancel,
}
+8
View File
@@ -177,6 +177,14 @@ impl<B: ClipboardBackend> ClipboardManager<B> {
Ok(disposition)
}
/// Clear only the current copy, without waiting or retaining previous data.
pub fn clear_current_copy(
&mut self,
copied: &SecretBytes,
) -> Result<ClipboardDisposition, ClipboardError> {
self.cleanup(copied, &ClipboardContent::EmptyOrNonText)
}
fn cleanup(
&mut self,
copied: &SecretBytes,
@@ -85,6 +85,28 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
authentication.lifecycle(MobileLifecycleEvent::Background)?;
assert!(!authentication.state()?.unlocked());
assert!(authentication.entry_editor(editor.id()).is_err());
for event in [
MobileLifecycleEvent::DeviceLocked,
MobileLifecycleEvent::MemoryPressure,
MobileLifecycleEvent::ProcessRecreated,
] {
authentication.unlock_entry(
"email/personal",
Some(SecretBytes::new(
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
)),
)?;
let editor = authentication.begin_entry_editor("email/personal")?;
clock.advance(Duration::from_secs(5));
assert_eq!(
authentication
.lifecycle(MobileLifecycleEvent::Foreground)?
.remaining_seconds(),
25
);
assert!(!authentication.lifecycle(event)?.unlocked());
assert!(authentication.entry_editor(editor.id()).is_err());
}
let baseline_locks = backend.locks();
drop(authentication);
assert!(
+22
View File
@@ -165,6 +165,28 @@ fn clearing_clipboard_never_reads_or_restores_previous_secrets() -> TestResult {
Ok(())
}
#[test]
fn immediate_copy_revocation_clears_only_matching_content() -> TestResult {
let backend = MemoryClipboard::with_text(b"copied secret");
let mut manager = ClipboardManager::new(backend.clone(), ClipboardTimeout::default());
let secret = SecretBytes::new(b"copied secret".to_vec());
assert_eq!(
manager.clear_current_copy(&secret)?,
ClipboardDisposition::Cleared
);
assert_eq!(backend.text(), None);
backend.set_text(b"new user value");
assert_eq!(
manager.clear_current_copy(&secret)?,
ClipboardDisposition::PreservedNewer
);
assert_eq!(
backend.text().as_deref(),
Some(b"new user value".as_slice())
);
Ok(())
}
#[test]
fn clipboard_validation_and_cleanup_failures_are_typed_and_redacted() -> TestResult {
assert_eq!(
+4 -3
View File
@@ -14,9 +14,10 @@ by Release shrinking. Certificate validation uses the operating system trust sto
Local setup generates or imports a private GPG key, verifies its passphrase, creates
Git and the upstream `.gpg-id`, and commits the configuration. No server, camera,
or biometric permission is needed. Encrypted prepared keys survive process death;
passphrases and tokens remain in memory only. Form secrets survive rotation through
a ViewModel and are excluded from saved-state Bundles. After process death transport
and passphrase inputs must be entered again.
passphrases and tokens remain in memory only and are excluded from saved-state
Bundles. Presentation revocation clears form secrets synchronously, including
backgrounding, rotation and memory pressure. Pending transport requests are cancelled
and released; transport and passphrase inputs must be entered again after returning.
HTTPS setup discovers branches with a temporary Rust repository, selects the branch,
and checks the selected private key against the cloned root recipient policy. Reusing
+112
View File
@@ -0,0 +1,112 @@
# Android presentation security
The Android client presents Rust-owned authentication state. `crates/storage`
owns lease expiry, renewal, editor disposal, and clipboard content comparison.
Kotlin forwards OS events and applies native window, input, and clipboard controls.
## Lifecycle and immediate cleanup
| Event | Behavior |
| --- | --- |
| Foreground/resume | Report foreground to Rust; remain locked until explicit unlock. |
| Explicit interaction | Report user activity while unlocked; Rust decides lease renewal. |
| Pause/stop/task switch | Immediately cover native content and clear protected presentation, then revoke the Rust lease. |
| Loss of top-resumed status | Revoke for an inactive multi-window activity, except while its own OS biometric request is pending. |
| Native window focus lost/user leaves task | Revoke even if Recents keeps the activity resumed; owned dialog/menu focus transfers remain usable. |
| Screen off/device locked | Cancel authentication and revoke the lease. |
| Trim memory/low memory | Clear protected presentation and revoke regardless of trim level. |
| Process recreation | Start locked; never restore a lease or plaintext from saved state. |
| Manual lock/expiry | Clear protected presentation, editors, OTP state, and active clipboard copies. |
`ProtectedPresentation` invokes main-thread cleanup synchronously. Its callbacks
clear setup secrets, cancel staged setup/remote operations, drop decrypted facade
sections and editor/TOTP state, clear local dialog inputs, and cover the old native
surface with an opaque overlay. Accessibility descendants are suppressed while
covered. A generation change discards the old composition; the cleaned content is
rendered before the resumed activity is uncovered. This keeps the frame clock
running while preventing a stale frame from being visible between cleanup and
recomposition. Rust revocation jobs run in order and complete before a subsequent
unlock; stale results cannot republish a protected document.
Owned dialogs and menus register their window before opening and report their own
focus loss. The activity excludes those internal focus transfers and its active
OS biometric request from external focus-loss revocation. The native cover stays
in place until the cleaned root is both resumed and focused again.
TOTP ticks, recomposition, polling, animations, clipboard waiting/cleanup, and
foreground notifications never report user activity. Rotation clears input and
cancels setup operations rather than saving passphrases, armor, or tokens.
## Windows, inputs, and diagnostics
The main activity sets `FLAG_SECURE` before Compose renders. Sensitive dialogs
use secure windows; other dialogs inherit the activity flag. API 33+ also disables
recent-task screenshots. Picture-in-picture is disabled in the manifest. An
activity launched on a secondary display is revoked and finished. Task switching
and inactive multi-window views clear protected presentation; returning does not
automatically authenticate. Screen sharing/casting to nonsecure surfaces uses the
same window policy. See Android's [secure-window contract](https://developer.android.com/reference/android/view/WindowManager.LayoutParams#FLAG_SECURE).
The activity and dialog roots exclude descendants from autofill and, on API 30+,
content capture. Input sessions request no personalized learning, fullscreen IME,
or extract UI. Password editors remain masked until an explicit reveal action.
Unlocked accessible fields remain available to assistive technology; locked
content is removed and covered. See the platform [view controls](https://developer.android.com/reference/android/view/View)
and [IME flags](https://developer.android.com/reference/android/view/inputmethod/EditorInfo).
The production Android modules have no secret logging, analytics, notification
payloads, or plaintext previews. Facade secret state and generated bridge records
have redacted diagnostic strings; errors use typed Rust messages or generic OS
failure text. Saved state contains navigation/setup choices, not protected input
or decrypted documents. Tests inspect serialized saved bundles and immediate
accessibility suppression. No memory-dump/export facility is provided.
The current manifest contains the main activity. Credential-provider components
are delivered by issue #109; they must use these protected presentation/window
controls when introduced. This issue does not install an empty credential service.
## Clipboard ownership and limits
Each explicit copy obtains a separate native clipboard capability. Rust supplies
the selected stored field and directs timeout/cancellation cleanup. Android marks
the clip sensitive, assigns a random owner identity, and records the OS timestamp.
Cleanup requires that identity, timestamp, and Rust's content comparison still
match. Another copy with identical text, unrelated user text, or a restored old
clip with a new timestamp is preserved. The adapter checks ownership again just
before clearing. A successful write publishes explicit copy feedback. Revocation
attempts cleanup synchronously while foreground clipboard access is still
available; no previous clipboard history is retained or restored.
Android's ClipboardManager does not offer an atomic compare-and-clear operation
across IPC calls. Another application can race the final check; this implementation
cannot promise atomicity that the OS API lacks. Android 10+ can deny background
clipboard reads, so cleanup cannot be guaranteed after the process has already
lost access or died. System/OEM clipboard histories may retain copies independently.
The sensitive flag suppresses supported clipboard previews; it is not encryption.
See [Android clipboard security](https://developer.android.com/privacy-and-security/risks/secure-clipboard-handling)
and [clipboard behavior](https://developer.android.com/develop/ui/views/touch-and-input/copy-paste).
`FLAG_SECURE`, autofill/content-capture exclusions, and IME requests rely on OS/OEM
and keyboard cooperation. They cannot protect against a rooted OS, debugger,
malicious keyboard, camera photographing the screen, or forensic memory access.
JVM/Compose strings cannot be reliably zeroized; cleanup drops references and
Rust zeroizes owned secret buffers, without claiming forensic JVM erasure.
## Validation
Rust tests exercise lifecycle revocation/editor disposal, foreground without
renewal, and immediate content-checked clipboard cleanup. Native facade tests
use real encrypted entries and verify synchronous clearing for background,
device lock, memory pressure, and process recreation. UI/OS tests cover native
clipboard ownership and restored metadata, sensitive clips, secure windows,
IME flags, saved bundles, rotation, and immediate accessibility masking.
The emulator matrix covers ARM64 API 26 (4 KB pages), API 37 (4 KB), and API 37
(strict 16 KB), including app/core/bridge instrumentation and five cold starts.
The existing embedded API 37 emulator additionally exercises real Keystore
biometric enrollment, unlock, cancellation, background cancellation, foreground
remaining locked, and memory-pressure revocation using disposable public fixture
credentials and its existing synthetic fingerprint. Computer use checks task
switching/recents and cleared input on return. ARMv7 receives build/package
validation; no suitable ARMv7 emulator image is available. Physical-device and
OEM-specific capture/casting coverage are excluded from this milestone's test
scope. Accessibility assertions do not claim spoken TalkBack verification.