diff --git a/android/README.md b/android/README.md index df416d5..00dcd06 100644 --- a/android/README.md +++ b/android/README.md @@ -1,12 +1,13 @@ # Native Android build Architecture and issue ownership: [ADR](../docs/android/architecture.md) and -[parity matrix](../docs/android/parity.md). The native foundation, onboarding, navigation and Home implement #89–#97, including private storage, Keystore +[parity matrix](../docs/android/parity.md). The native foundation, onboarding, navigation, Home and authentication implement #89–#98, including private storage, Keystore callbacks and build gates. Both variants invoke Rust through the generated UniFFI API. Local-first and HTTPS onboarding is described in [onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned, revision-checked [adaptive navigation](../docs/android/password-navigation.md); Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.md). +Passwords and Preferences share [Rust-owned biometric authentication](../docs/android/authentication.md). Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the ARM-only runner/toolchain policy and artifact audits. diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationLeaseTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationLeaseTest.kt new file mode 100644 index 0000000..b1d708f --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationLeaseTest.kt @@ -0,0 +1,231 @@ +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 +import androidx.compose.material3.TextButton +import androidx.compose.runtime.collectAsState +import androidx.lifecycle.Lifecycle +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.KeystoreAuthorization +import de.rfc1437.ironstorage.core.KeystoreCiphertext +import de.rfc1437.ironstorage.core.KeystoreKeySpecification +import de.rfc1437.ironstorage.core.NativeAuthenticationRepository +import de.rfc1437.ironstorage.core.NativeFixtureShellRepository +import de.rfc1437.ironstorage.core.NativeOnboardingRepository +import de.rfc1437.ironstorage.core.NativePrivateSession +import de.rfc1437.ironstorage.core.PrivateDirectories +import de.rfc1437.ironstorage.features.AuthenticationControls +import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter +import java.io.File +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +/** Real OS/Rust integration using public fixtures and only production Kotlin facades. */ +class AuthenticationLeaseTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context = instrumentation.targetContext + + @Test fun strongBiometricLeaseCancellationBackgroundAndManualRecovery() = runBlocking { + assumeTrue(InstrumentationRegistry.getArguments().getString("biometricLease") == "true") + val os = AndroidKeystoreAdapter(context) + assertTrue( + "Enroll a synthetic strong fingerprint for this opt-in test", + os.deviceState().strongBiometric + ) + val id = "auth-${UUID.randomUUID()}" + val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map { + File(it, id).apply { mkdirs() }.canonicalPath + } + val isolated = FixtureKeystore(os, id) + val session = + NativePrivateSession(context, { + PrivateDirectories(roots[0], roots[1], roots[2]) + }, isolated) + // Select the isolated Rust context before any activity creates its default runtime. + withContext(Dispatchers.IO) { + session.initialize() + } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val shell = NativeFixtureShellRepository(session, scope) + val setup = NativeOnboardingRepository(session, scope, shell) + val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO) + val password = "public-test-passphrase" + suspend fun settled() = withTimeout(180000) { + authentication.state.first { !it.busy } + } + suspend fun request(action: () -> Unit) { + withContext(Dispatchers.Main.immediate) { action() } + } + val stage = MutableStateFlow("Authentication fixture") + val step = AtomicReference() + fun show(value: String) { + android.util.Log.i("IronStorage98", value) + stage.value = value + } + fun awaitStep(value: String): Boolean { + val latch = CountDownLatch(1) + step.set(latch) + show(value) + return try { + latch.await(180, TimeUnit.SECONDS) + } finally { + step.set(null) + } + } + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + lateinit var host: MainActivity + scenario.onActivity { + host = it + os.attach(it) + it.setContent { + MaterialTheme { + Column { + Text(stage.collectAsState().value) + TextButton(onClick = { + step.get()?.countDown() + }) { Text("Continue fixture") } + AuthenticationControls(authentication) + } + } + } + } + try { + withTimeout(20000) { setup.setup.first { !it.busy } } + request { setup.generate("Authentication fixture", password) } + val generated = withTimeout(20000) { + setup.setup.first { + !it.busy && + it.selectedKey.isNotEmpty() + } + } + request { setup.createLocal(generated.selectedKey, password) } + withTimeout(20000) { setup.setup.first { !it.busy && it.configured } } + show("98:enroll") + request { authentication.enroll(password) } + assertTrue(settled().biometricEnabled) + request { authentication.lock() } + assertFalse(authentication.state.value.unlocked) + show("98:unlock") + request { authentication.unlock() } + assertTrue(settled().unlocked) + request { authentication.reload() } + assertTrue(settled().unlocked) + request { authentication.lock() } + show("98:cancel") + request { authentication.unlock() } + assertEquals(AuthenticationFailureKind.Cancelled, settled().failure?.kind) + assertFalse(authentication.state.value.unlocked) + assertTrue(awaitStep("98:background-start")) + request { authentication.unlock() } + instrumentation.waitForIdleSync() + scenario.moveToState(Lifecycle.State.CREATED) + request { authentication.lock() } + assertFalse(settled().unlocked) + scenario.moveToState(Lifecycle.State.RESUMED) + request { authentication.disable() } + assertFalse(settled().biometricEnabled) + show("98:reenroll") + request { authentication.enroll(password) } + assertTrue(settled().unlocked) + request { authentication.lock() } + assertTrue(awaitStep("98:invalidate-now")) + request { authentication.unlock() } + val invalid = settled() + assertFalse(invalid.unlocked) + assertTrue( + invalid.failure?.kind == AuthenticationFailureKind.PassphraseRequired || + invalid.failure?.kind == AuthenticationFailureKind.BiometryUnavailable + ) + request { authentication.disable() } + assertFalse(settled().biometricEnabled) + show("98:recover") + request { authentication.enroll(password) } + assertTrue(settled().unlocked) + request { authentication.lock() } + assertTrue(awaitStep("98:remove-lock")) + request { authentication.unlock() } + val removed = settled() + assertFalse(removed.unlocked) + assertTrue( + removed.failure?.kind == AuthenticationFailureKind.PassphraseRequired || + removed.failure?.kind == AuthenticationFailureKind.BiometryUnavailable + ) + request { authentication.disable() } + assertFalse(settled().biometricEnabled) + request { authentication.enroll(password) } + val unsupported = settled() + assertFalse(unsupported.unlocked) + assertFalse(unsupported.biometricEnabled) + assertEquals( + AuthenticationFailureKind.BiometryUnavailable, + unsupported.failure?.kind + ) + assertTrue(awaitStep("98:restore-lock")) + show("98:recover-lock") + request { authentication.enroll(password) } + assertTrue(settled().unlocked) + request { authentication.disable() } + assertFalse(settled().unlocked) + show("98:complete") + } finally { + authentication.lock() + os.cancelPending() + os.detach(host) + scope.cancel() + isolated.aliases().forEach { isolated.deleteKey(it) } + } + } + } + + /** Only OS aliases are namespaced so orphan cleanup cannot touch the production store. */ + private class FixtureKeystore(private val os: AndroidKeystore, id: String) : + AndroidKeystore by os { + private val prefix = "ironstorage.issue98.fixture.$id." + override fun createKey(specification: KeystoreKeySpecification) = os.createKey( + KeystoreKeySpecification( + prefix + specification.alias, + specification.authorization, + specification.unlockedDevice, + specification.invalidateOnEnrollment, + specification.strongbox + ) + ) + override fun encrypt( + alias: String, + authorization: KeystoreAuthorization, + aad: ByteArray, + value: ByteArray + ): KeystoreCiphertext = os.encrypt(prefix + alias, authorization, aad, value) + override fun decrypt( + alias: String, + authorization: KeystoreAuthorization, + aad: ByteArray, + value: KeystoreCiphertext + ): ByteArray = os.decrypt(prefix + alias, authorization, aad, value) + override fun deleteKey(alias: String) = os.deleteKey(prefix + alias) + override fun aliases(): List = os.aliases().filter { + it.startsWith(prefix) + }.map { it.removePrefix(prefix) } + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationScreenTest.kt new file mode 100644 index 0000000..cc672ac --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AuthenticationScreenTest.kt @@ -0,0 +1,112 @@ +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 +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.Lifecycle +import de.rfc1437.ironstorage.core.AuthenticationFailure +import de.rfc1437.ironstorage.core.AuthenticationFailureKind +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.AuthenticationState +import de.rfc1437.ironstorage.features.AuthenticationControls +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class AuthenticationScreenTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun enrollmentRequiresExplicitSubmissionAndTypedSuccessBeforeDisplayingEnabled() { + val repository = Fixture() + compose.activity.runOnUiThread { + compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } } + } + compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed() + compose.onNodeWithText("Enable biometric unlock").performClick() + compose.onNodeWithText("Continue").assertIsNotEnabled() + compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase") + compose.onNodeWithText("Continue").performClick() + assertEquals("public-test-passphrase", repository.submitted) + compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed() + compose.runOnIdle { + repository.state.value = AuthenticationState( + failure = AuthenticationFailure( + AuthenticationFailureKind.Cancelled, + "Authentication cancelled", + "No protected content was unlocked." + ) + ) + } + compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed() + compose.runOnIdle { repository.state.value = AuthenticationState(true, true, 30u) } + compose.onNodeWithText("Biometric unlock enabled").assertIsDisplayed() + compose.onNodeWithText("Lock").performClick() + assertTrue(repository.locked) + compose.onNodeWithText("Unlock").performClick() + assertEquals(1, repository.unlocks) + compose.onNodeWithText("Disable biometric unlock").performClick() + assertEquals(1, repository.disabled) + compose.onNodeWithText("Biometric unlock disabled").assertIsDisplayed() + } + + @Test fun cancellationBackgroundAndRemountNeverRestoreThePassphrase() { + val repository = Fixture() + fun mount() = compose.activity.runOnUiThread { + compose.activity.setContent { MaterialTheme { AuthenticationControls(repository) } } + } + mount() + compose.onNodeWithText("Enable biometric unlock").performClick() + compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase") + compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithText("Enable biometric unlock").performClick() + compose.onNodeWithText("Continue").assertIsNotEnabled() + compose.onNodeWithText("GPG passphrase").performTextInput("another-public-fixture") + compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + compose.onNodeWithText("GPG passphrase").assertDoesNotExist() + compose.onNodeWithText("Enable biometric unlock").performClick() + compose.onNodeWithText("Continue").assertIsNotEnabled() + compose.activity.runOnUiThread { compose.activity.setContent { MaterialTheme { } } } + mount() + compose.onNodeWithText("Enable biometric unlock").performClick() + compose.onNodeWithText("Continue").assertIsNotEnabled() + assertEquals(null, repository.submitted) + assertTrue(repository.cancelled > 0) + } + + private class Fixture : AuthenticationRepository { + override val state = MutableStateFlow(AuthenticationState()) + var submitted: String? = null + var locked = false + var unlocks = 0 + var disabled = 0 + var cancelled = 0 + override fun reload() = Unit + override fun enroll(passphrase: String) { + submitted = passphrase + } + override fun unlock() { + unlocks++ + } + override fun disable() { + disabled++ + state.value = AuthenticationState() + } + override fun lock() { + locked = true + state.value = state.value.copy(unlocked = false) + } + override fun cancel() { + cancelled++ + state.value = state.value.copy(unlocked = false) + } + override fun touch() = Unit + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt index d129851..713af8b 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt @@ -23,6 +23,10 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextInput import androidx.compose.ui.unit.Density +import de.rfc1437.ironstorage.core.AuthenticationFailure +import de.rfc1437.ironstorage.core.AuthenticationFailureKind +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.AuthenticationState import de.rfc1437.ironstorage.core.PasswordField import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordRepository @@ -54,7 +58,7 @@ class PasswordScreenTest { compose.activity.runOnUiThread { compose.activity.setContent { inputMode = LocalInputModeManager.current - MaterialTheme { PasswordScreen(repository) } + MaterialTheme { PasswordScreen(repository, repository.authentication) } } } compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) } @@ -84,12 +88,19 @@ class PasswordScreenTest { compose.onAllNodesWithText("Actions")[0].performClick() compose.onNodeWithText("Open").performClick() compose.onNodeWithText("Unlock").assertIsDisplayed() + compose.onNodeWithText("Unlock").performClick() compose.onNodeWithText("GPG passphrase").performTextInput("incorrect") - compose.onNodeWithText("Unlock").performClick() + compose.onNodeWithText("Continue").performClick() compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed() + compose.onNodeWithText("Use GPG passphrase").performClick() compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase") - compose.onNodeWithText("Unlock").performClick() + compose.onNodeWithText("Continue").performClick() compose.onNodeWithText("Fixture account").assertIsDisplayed() + // Revocation must mask even a page result that has not been cleared yet. + compose.runOnIdle { repository.lease.value = AuthenticationState() } + compose.onNodeWithText("Fixture account").assertDoesNotExist() + compose.onNodeWithText("Unlock").assertIsDisplayed() + compose.runOnIdle { repository.lease.value = AuthenticationState(unlocked = true) } compose.onNodeWithText("Lock").performClick() compose.onNodeWithText("Unlock").assertIsDisplayed() repository.replaceOnRefresh = true @@ -117,7 +128,7 @@ class PasswordScreenTest { compose.activity.setContent { val density = LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) { - MaterialTheme { PasswordScreen(repository) } + MaterialTheme { PasswordScreen(repository, repository.authentication) } } } } @@ -151,6 +162,19 @@ class PasswordScreenTest { ) override val state = MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder))) + val lease = MutableStateFlow(AuthenticationState()) + val authentication = object : AuthenticationRepository { + override val state = lease + override fun reload() = Unit + override fun unlock() = error("Unexpected direct unlock") + override fun enroll(passphrase: String) = error("Unexpected enrollment") + override fun disable() = error("Unexpected disable") + override fun lock() { + state.value = state.value.copy(unlocked = false) + } + override fun cancel() = lock() + override fun touch() = Unit + } var lastId: String? = null var replaceOnRefresh = false override fun restore(path: String, selected: String?, revision: String?) = Unit @@ -190,10 +214,21 @@ class PasswordScreenTest { } } override fun unlock(passphrase: String) { + if (passphrase.isEmpty()) { + lease.value = AuthenticationState( + failure = AuthenticationFailure( + AuthenticationFailureKind.PassphraseRequired, + "Passphrase Required", + "Enter the passphrase." + ) + ) + return + } if (passphrase != "public-test-passphrase") { state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.") return } + lease.value = AuthenticationState(unlocked = true) assertEquals("public-test-passphrase", passphrase) state.value = state.value.copy( diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt index 2797779..6e1e3b9 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/MainActivity.kt @@ -7,6 +7,8 @@ import androidx.activity.viewModels import androidx.compose.material3.MaterialTheme import androidx.fragment.app.FragmentActivity import dagger.hilt.android.AndroidEntryPoint +import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.features.SetupFormViewModel import de.rfc1437.ironstorage.features.ShellViewModel @@ -19,10 +21,24 @@ class MainActivity : FragmentActivity() { @Inject lateinit var repository: ShellRepository + @Inject lateinit var authentication: AuthenticationRepository + + @Inject lateinit var passwords: PasswordRepository + private val setupForm: SetupFormViewModel by viewModels() private val viewModel: ShellViewModel by viewModels() + override fun onStop() { + passwords.lock() + super.onStop() + } + + override fun onUserInteraction() { + authentication.touch() + super.onUserInteraction() + } + override fun onDestroy() { keystore.detach(this) super.onDestroy() diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index bced4a5..44af54c 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -6,7 +6,9 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import de.rfc1437.ironstorage.core.AuthenticationRepository import de.rfc1437.ironstorage.core.HomeRepository +import de.rfc1437.ironstorage.core.NativeAuthenticationRepository import de.rfc1437.ironstorage.core.NativeFixtureShellRepository import de.rfc1437.ironstorage.core.NativeHomeRepository import de.rfc1437.ironstorage.core.NativeOnboardingRepository @@ -39,12 +41,29 @@ object ShellModule { @Provides @Singleton - fun passwordRepository(session: NativePrivateSession): PasswordRepository = - NativePasswordRepository( + fun passwordRepository( + session: NativePrivateSession, + authentication: NativeAuthenticationRepository + ): PasswordRepository = NativePasswordRepository( + session, + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + authentication, + Dispatchers.IO + ) + + @Provides + @Singleton + fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository = + NativeAuthenticationRepository( session, - CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + Dispatchers.IO ) + @Provides + fun authentication(repository: NativeAuthenticationRepository): AuthenticationRepository = + repository + @Provides @Singleton fun privateSession( diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AuthenticationRepository.kt new file mode 100644 index 0000000..1055c3a --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/AuthenticationRepository.kt @@ -0,0 +1,38 @@ +package de.rfc1437.ironstorage.core + +import kotlinx.coroutines.flow.StateFlow + +enum class AuthenticationFailureKind { + PassphraseRequired, + InvalidPassphrase, + Cancelled, + BiometryUnavailable, + Configuration, + KeyMaterial, + Entry, + SecureStorage, + Expired, + Conflict +} +data class AuthenticationFailure( + val kind: AuthenticationFailureKind, + val title: String, + val detail: String +) +data class AuthenticationState( + val unlocked: Boolean = false, + val biometricEnabled: Boolean = false, + val remainingSeconds: ULong = 0u, + val busy: Boolean = false, + val failure: AuthenticationFailure? = null +) +interface AuthenticationRepository { + val state: StateFlow + fun reload() + fun unlock() + fun enroll(passphrase: String) + fun disable() + fun lock() + fun cancel() + fun touch() +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt new file mode 100644 index 0000000..a08c9de --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt @@ -0,0 +1,206 @@ +package de.rfc1437.ironstorage.core + +import android.os.SystemClock +import de.rfc1437.ironstorage.bridge.NativeBridgeException +import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication +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.MobilePrivateStorageFfiException +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** Mirrors the single Rust lease; no timeout or biometric policy lives here. */ +class NativeAuthenticationRepository( + private val session: NativePrivateSession, + private val scope: CoroutineScope, + private val io: CoroutineDispatcher +) : AuthenticationRepository { + private val mutable = MutableStateFlow(AuthenticationState()) + override val state: StateFlow = mutable + private val sequence = AtomicLong() + private val operation = Mutex() + private var revocation: Job? = null + + @Volatile private var current: MobileAuthentication? = null + private val clock = object : MobileClock { + override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong() + } + init { + scope.launch { + while (true) { + delay(250) + if (mutable.value.unlocked && !mutable.value.busy) { + val ticket = sequence.get() + try { + val value = withContext(io) { current?.state() } + if (ticket == sequence.get() && value != null) { + mutable.value = value.toFacade() + } + } catch (_: MobileAuthenticationFfiException) { + if (ticket == sequence.get()) lock() + } catch (_: NativeBridgeException) { + if (ticket == sequence.get()) lock() + } + } + } + } + } + private fun owner(): MobileAuthentication = + session.storage().authentication(session.backend(), clock).also { current = it } + + internal suspend fun authenticated(block: (MobileAuthentication) -> T): T = + withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { + val ticket = sequence.incrementAndGet() + mutable.value = mutable.value.copy(busy = true, failure = null) + operation.withLock { + revocation?.join() + if (ticket != sequence.get()) throw cancelled() + try { + val result = withContext(io) { + val authentication = owner() + if (ticket != sequence.get()) throw cancelled() + val result = block(authentication) + if (ticket != sequence.get()) { + authentication.manualLock() + throw cancelled() + } + result + } + val value = withContext(io) { current!!.state() } + if (ticket == sequence.get()) mutable.value = 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() + ) + } + throw error + } catch (error: MobilePrivateStorageFfiException) { + if (ticket == sequence.get()) unavailable() + throw error + } catch (error: NativeBridgeException) { + if (ticket == sequence.get()) unavailable() + throw error + } catch (error: java.io.IOException) { + if (ticket == sequence.get()) unavailable() + throw error + } finally { + if (ticket == sequence.get()) mutable.value = mutable.value.copy(busy = false) + } + } + } + override fun reload() = run { it.state() } + override fun unlock() = run { it.restoreBiometricLease() } + override fun enroll(passphrase: String) = run { it.enrollBiometricUnlock(passphrase) } + override fun disable() = run { it.setBiometricUnlock(false) } + private fun run(block: (MobileAuthentication) -> Unit) { + if (mutable.value.busy) return + scope.launch { + try { + authenticated(block) + } catch (_: MobileAuthenticationFfiException) { + // The typed failure is published by authenticated(). + } catch (_: MobilePrivateStorageFfiException) { + // The failure is published before releasing the operation. + } catch (_: NativeBridgeException) { + // The failure is published before releasing the operation. + } catch (_: java.io.IOException) { + // The failure is published before releasing the operation. + } + } + } + private fun unavailable() { + mutable.value = 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) { + val ticket = sequence.incrementAndGet() + mutable.value = mutable.value.copy( + unlocked = false, + remainingSeconds = 0u, + busy = false, + failure = if (cancel) { + AuthenticationFailure( + AuthenticationFailureKind.Cancelled, + "Authentication cancelled", + "No protected content was unlocked." + ) + } else { + null + } + ) + session.cancelAuthentication() + val authentication = current + revocation = scope.launch(io) { + try { + if (cancel) authentication?.cancel() else authentication?.manualLock() + } catch (_: MobileAuthenticationFfiException) { + withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { + if (ticket == sequence.get()) unavailable() + } + } + } + } + override fun touch() { + if (!mutable.value.unlocked || mutable.value.busy) return + val ticket = sequence.get() + scope.launch(io) { + try { + current?.touchUserActivity() + } catch (_: MobileAuthenticationFfiException) { + withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { + if (ticket == sequence.get()) lock() + } + } + } + } +} +private fun cancelled() = MobileAuthenticationFfiException.Failed( + MobileAuthenticationErrorKind.CANCELLED, + "Authentication cancelled", + "No protected content was unlocked." +) +private fun MobileAuthenticationState.toFacade() = AuthenticationState( + unlocked, + biometricUnlockEnabled, + remainingSeconds +) +private fun MobileAuthenticationFfiException.Failed.toFacade() = AuthenticationFailure( + when (kind) { + MobileAuthenticationErrorKind.PASSPHRASE_REQUIRED -> + AuthenticationFailureKind.PassphraseRequired + MobileAuthenticationErrorKind.INVALID_PASSPHRASE -> + AuthenticationFailureKind.InvalidPassphrase + MobileAuthenticationErrorKind.CANCELLED -> AuthenticationFailureKind.Cancelled + MobileAuthenticationErrorKind.BIOMETRY_UNAVAILABLE -> + AuthenticationFailureKind.BiometryUnavailable + MobileAuthenticationErrorKind.CONFIGURATION -> AuthenticationFailureKind.Configuration + MobileAuthenticationErrorKind.KEY_MATERIAL -> AuthenticationFailureKind.KeyMaterial + MobileAuthenticationErrorKind.ENTRY -> AuthenticationFailureKind.Entry + MobileAuthenticationErrorKind.SECURE_STORAGE -> AuthenticationFailureKind.SecureStorage + MobileAuthenticationErrorKind.EXPIRED -> AuthenticationFailureKind.Expired + MobileAuthenticationErrorKind.CONFLICT -> AuthenticationFailureKind.Conflict + }, + title, + detail +) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt index c56c2a7..c4ae1f2 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt @@ -1,18 +1,14 @@ package de.rfc1437.ironstorage.core -import android.os.SystemClock import de.rfc1437.ironstorage.bridge.NativeBridgeException -import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException -import de.rfc1437.ironstorage.bridge.generated.MobileClock import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -22,35 +18,18 @@ import kotlinx.coroutines.withContext class NativePasswordRepository( private val session: NativePrivateSession, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val authentication: NativeAuthenticationRepository, + private val io: CoroutineDispatcher ) : PasswordRepository { private val mutable = MutableStateFlow(PasswordState()) override val state: StateFlow = mutable private val sequence = AtomicLong() private val mutex = Mutex() - private var authentication: MobileAuthentication? = null - private val clock = object : MobileClock { - override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong() - } - init { scope.launch { - while (true) { - delay(1000) - if (mutable.value.sections != null) { - mutex.withLock { - val active = try { - withContext(Dispatchers.IO) { - authentication?.state()?.unlocked == true - } - } catch (_: MobileAuthenticationFfiException) { - false - } catch (_: NativeBridgeException) { - false - } - if (!active) mutable.value = mutable.value.copy(sections = null) - } - } + authentication.state.collect { lease -> + if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null) } } } @@ -81,8 +60,6 @@ class NativePasswordRepository( } private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) = run { - authentication?.manualLock() - authentication = null session.storage().passwordNavigation(path, selected, revision, activate).toFacade() } override fun unlock(passphrase: String) { @@ -97,19 +74,15 @@ class NativePasswordRepository( false ).toFacade() if (verified.selected == null) return@run verified - val auth = - authentication - ?: session.storage().authentication(session.backend(), clock).also { - authentication = - it - } - val page = auth.navigationEntry( - current.path, - selected.id, - current.revision, - passphrase.ifEmpty { null }, - (System.currentTimeMillis() / 1000).toULong() - ) + val page = authentication.authenticated { auth -> + auth.navigationEntry( + current.path, + selected.id, + current.revision, + passphrase.ifEmpty { null }, + (System.currentTimeMillis() / 1000).toULong() + ) + } verified.copy( sections = page.page.sections.map { section -> PasswordSection( @@ -130,26 +103,16 @@ class NativePasswordRepository( override fun lock() { sequence.incrementAndGet() mutable.value = mutable.value.copy(sections = null, loading = false) - scope.launch { - mutex.withLock { - try { - withContext(Dispatchers.IO) { authentication?.manualLock() } - } catch (_: MobileAuthenticationFfiException) { - mutable.value = mutable.value.copy( - error = "Protected content was cleared. Retry locking the device storage." - ) - } - } - } + authentication.lock() } - private fun run(action: () -> PasswordState) { + private fun run(action: suspend () -> PasswordState) { val ticket = sequence.incrementAndGet() mutable.value = mutable.value.copy(loading = true, error = null, sections = null) scope.launch { mutex.withLock { if (sequence.get() != ticket) return@withLock val result = try { - withContext(Dispatchers.IO) { action() } + withContext(io) { action() } } catch (error: MobilePasswordFfiException.Failed) { mutable.value.copy(error = error.detail, selected = null) } catch (error: MobileAuthenticationFfiException.Failed) { @@ -167,9 +130,10 @@ class NativePasswordRepository( ) } if (sequence.get() == ticket) { - mutable.value = result.copy(loading = false) - } else { - withContext(Dispatchers.IO) { authentication?.manualLock() } + mutable.value = result.copy( + loading = false, + sections = result.sections.takeIf { authentication.state.value.unlocked } + ) } } } diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePrivateSession.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePrivateSession.kt index 6de1edc..ac41016 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePrivateSession.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePrivateSession.kt @@ -13,6 +13,12 @@ class NativePrivateSession( private val keystore: AndroidKeystore ) { val runtime = NativeRuntime(context) + + fun cancelAuthentication() = keystore.cancelPending() + + fun initialize() { + storage() + } private var initialized: MobilePrivateRuntime? = null private var secrets: MobileSecretBackend? = null diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/AuthenticationControls.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/AuthenticationControls.kt new file mode 100644 index 0000000..db816bb --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/AuthenticationControls.kt @@ -0,0 +1,147 @@ +package de.rfc1437.ironstorage.features + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.rfc1437.ironstorage.core.AuthenticationRepository + +@Composable +fun AuthenticationControls(repository: AuthenticationRepository) { + val state by repository.state.collectAsStateWithLifecycle() + var enrollment by remember { mutableStateOf(false) } + LaunchedEffect(repository) { repository.reload() } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { enrollment = false } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.auth_title)) + Text( + stringResource( + if (state.biometricEnabled) R.string.auth_enabled else R.string.auth_disabled + ) + ) + state.failure?.let { Text(it.title + "\n" + it.detail) } + if (state.busy) { + CircularProgressIndicator() + TextButton(onClick = repository::cancel) { Text(stringResource(R.string.auth_cancel)) } + } else { + TextButton(onClick = { + if (state.biometricEnabled) repository.disable() else enrollment = true + }) { + Text( + stringResource( + if (state.biometricEnabled) R.string.auth_disable else R.string.auth_enable + ) + ) + } + if (!state.unlocked && state.biometricEnabled) { + TextButton(onClick = repository::unlock) { + Text(stringResource(R.string.password_unlock)) + } + TextButton(onClick = { + enrollment = true + }) { Text(stringResource(R.string.auth_manual)) } + } + if (state.unlocked) { + Text( + pluralStringResource( + R.plurals.auth_lease, + state.remainingSeconds.toInt(), + state.remainingSeconds.toLong() + ) + ) + TextButton(onClick = repository::lock) { + Text(stringResource(R.string.password_lock)) + } + } + } + } + if (enrollment) { + PassphraseDialog( + stringResource(R.string.auth_enroll_title), + stringResource(R.string.auth_enroll_detail), + onCancel = { + enrollment = false + repository.cancel() + }, + onSubmit = { + enrollment = false + repository.enroll(it) + } + ) + } +} + +@Composable +internal fun PassphraseDialog( + title: String, + detail: String, + onCancel: () -> Unit, + onSubmit: (String) -> Unit +) { + var secret by remember { mutableStateOf("") } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + secret = "" + onCancel() + } + DisposableEffect(Unit) { onDispose { secret = "" } } + AlertDialog( + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + onDismissRequest = { + secret = "" + onCancel() + }, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(detail) + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text(stringResource(R.string.setup_passphrase)) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password + ), + singleLine = true + ) + } + }, + confirmButton = { + TextButton(enabled = secret.isNotEmpty(), onClick = { + val submitted = secret + secret = "" + onSubmit(submitted) + }) { Text(stringResource(R.string.auth_continue)) } + }, + dismissButton = { + TextButton(onClick = { + secret = "" + onCancel() + }) { + Text(stringResource(R.string.auth_cancel)) + } + } + ) +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt index 8339ad0..2344650 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/PasswordScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @@ -26,7 +25,6 @@ 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 @@ -41,25 +39,33 @@ import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.rfc1437.ironstorage.core.AuthenticationFailureKind +import de.rfc1437.ironstorage.core.AuthenticationRepository import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.ShellIcon @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable -fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) { +fun PasswordScreen( + repository: PasswordRepository, + authentication: AuthenticationRepository, + modifier: Modifier = Modifier +) { val state by repository.state.collectAsStateWithLifecycle() val navigator = rememberListDetailPaneScaffoldNavigator() var actions by remember { mutableStateOf(false) } - var passphrase by remember { mutableStateOf("") } + val lease by authentication.state.collectAsStateWithLifecycle() + var manual by remember { mutableStateOf(false) } + var requested by remember { mutableStateOf(false) } val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry } LaunchedEffect(entry?.id) { - passphrase = "" + manual = false + requested = false navigator.navigateTo( if (entry == null @@ -72,13 +78,13 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier ) } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - passphrase = "" + manual = false + requested = false repository.lock() } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { if (state.revision.isNotEmpty()) repository.refresh() } - DisposableEffect(repository) { onDispose { repository.lock() } } BackHandler(state.selected != null || state.parent != null) { actions = false repository.back() @@ -217,28 +223,22 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier Text(it, color = MaterialTheme.colorScheme.error) } } - val sections = state.sections + val sections = state.sections.takeIf { lease.unlocked } if (sections == null) { item { Text(entry.detail) - OutlinedTextField( - passphrase, - { - passphrase = it - }, - label = { - Text(stringResource(R.string.setup_passphrase)) - }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - enabled = !state.loading - ) - Button(enabled = !state.loading, onClick = { - val submitted = passphrase - passphrase = - "" - repository.unlock(submitted) + Button(enabled = !state.loading && !lease.busy, onClick = { + requested = true + repository.unlock("") }) { Text(stringResource(R.string.password_unlock)) } + TextButton(enabled = !state.loading && !lease.busy, onClick = { + manual = true + }) { Text(stringResource(R.string.auth_manual)) } + if (lease.busy) { + TextButton(onClick = authentication::cancel) { + Text(stringResource(R.string.auth_cancel)) + } + } } } else { item { @@ -274,6 +274,37 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier } ) } + LaunchedEffect(lease.failure, state.loading) { + if (requested && !state.loading && !lease.busy) { + requested = false + if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired || + lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable + ) { + manual = true + } + } + } + if (manual && entry != null) { + PassphraseDialog( + stringResource(R.string.auth_manual), + stringResource( + if (lease.biometricEnabled) { + R.string.auth_recover_detail + } else { + R.string.auth_manual_detail + } + ), + onCancel = { + manual = false + authentication.cancel() + }, + onSubmit = { + manual = false + requested = true + repository.unlock(it) + } + ) + } if (actions && state.selected != null && !state.loading) { val selected = requireNotNull(state.selected) AlertDialog( diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index 55da6ca..e9109d2 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -91,10 +91,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) { snapshot.storageRecovery == null && (snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences) ) { + if (ready && snapshot.selectedTab == ShellTab.Preferences) { + AuthenticationControls(viewModel.authentication) + } OnboardingScreen(viewModel.onboarding, setupForm) } else if (browsing) { LaunchedEffect(viewModel) { viewModel.openPasswords() } - PasswordScreen(viewModel.passwords, Modifier.weight(1f)) + PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f)) } else if (home) { HomeScreen(viewModel.home, Modifier.weight(1f)) } else { diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt index 97df63b..b79e947 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.rfc1437.ironstorage.core.AuthenticationRepository import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.PasswordRepository @@ -18,6 +19,7 @@ class ShellViewModel @Inject constructor( val onboarding: OnboardingRepository, val passwords: PasswordRepository, val home: HomeRepository, + val authentication: AuthenticationRepository, private val saved: SavedStateHandle ) : ViewModel() { val snapshot = repository.snapshot @@ -47,11 +49,6 @@ class ShellViewModel @Inject constructor( } } fun selectTab(tab: ShellTab) { - if (tab != - ShellTab.Passwords - ) { - passwords.lock() - } repository.selectTab(tab) } override fun onCleared() { diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 2cee15e..a4408e7 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -63,4 +63,20 @@ Close Unlock Lock + Authentication + Biometric unlock enabled + Biometric unlock disabled + Enable biometric unlock + Disable biometric unlock + Use GPG passphrase + The passphrase is validated by Rust and retained only for the authentication lease. + After validating the passphrase, renew the enabled biometric record using a matching biometric. + Enroll biometric unlock + Validate the GPG key passphrase, then use a strong biometric to protect it for future unlocks. A device PIN cannot substitute for a matching biometric. + Continue + Cancel + + Unlocked: %1$d second remaining + Unlocked: %1$d seconds remaining + diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 0d77a45..22d8834 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -1762,6 +1762,27 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn enroll_biometric_unlock( + &self, + passphrase: String, + ) -> Result { + self.authentication + .enroll_biometric_unlock(ironstorage::repository::SecretBytes::new( + passphrase.into_bytes(), + )) + .map(Into::into) + .map_err(Into::into) + } + + pub fn restore_biometric_lease( + &self, + ) -> Result { + self.authentication + .restore_biometric_lease() + .map(Into::into) + .map_err(Into::into) + } + pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationFfiError> { self.authentication .touch_user_activity() diff --git a/crates/storage/src/authentication.rs b/crates/storage/src/authentication.rs index 8450b77..070cea7 100644 --- a/crates/storage/src/authentication.rs +++ b/crates/storage/src/authentication.rs @@ -310,14 +310,18 @@ impl AuthenticationSession /// Revoke every outstanding handle immediately. pub fn manual_lock(&self) -> Result<(), AuthenticationError> { + let interrupted = self.shared.store.cancel_pending(); let _operation = self.shared.operation()?; - self.shared.revoke(RevocationReason::Manual) + self.shared.revoke(RevocationReason::Manual)?; + interrupted.map_err(Into::into) } /// Cancel authentication and revoke any lease created by the pending interaction. pub fn cancel(&self) -> Result<(), AuthenticationError> { + let interrupted = self.shared.store.cancel_pending(); let _operation = self.shared.operation()?; - self.shared.revoke(RevocationReason::Cancelled) + self.shared.revoke(RevocationReason::Cancelled)?; + interrupted.map_err(Into::into) } /// Apply a new shared inactivity timeout to this process and any active lease. diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 61a180c..ad1942d 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -481,10 +481,10 @@ impl MobileAuthentication { match self.keys.decrypt(ciphertext, &mut provider) { Ok(plaintext) => { drop(plaintext); - if biometric_enabled { - handle - .persist_passphrase(key) - .map_err(MobileAuthenticationError::authentication)?; + if biometric_enabled && let Err(error) = handle.persist_passphrase(key) + { + let _ = self.manual_lock(); + return Err(MobileAuthenticationError::authentication(error)); } self.set_active(handle, key.clone())?; return self.state(); @@ -494,7 +494,10 @@ impl MobileAuthentication { .manual_lock() .map_err(MobileAuthenticationError::authentication)?; } - Err(error) => return Err(key_error(error)), + Err(error) => { + let _ = self.manual_lock(); + return Err(key_error(error)); + } } } Err(MobileAuthenticationError::new( @@ -504,6 +507,7 @@ impl MobileAuthentication { )) } None if biometric_enabled => { + let mut failure = None; for key in &candidates { let handle = match self.session.authenticate(key) { Ok(handle) => handle, @@ -512,7 +516,10 @@ impl MobileAuthentication { AuthenticationError::Cancelled, )); } - Err(_) => continue, + Err(error) => { + failure = Some(MobileAuthenticationError::authentication(error)); + continue; + } }; let mut provider = KeyOnlyProvider::new(handle.clone(), key); if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) { @@ -523,9 +530,9 @@ impl MobileAuthentication { let _ = self.session.delete_key_passphrase(key); let _ = self.session.manual_lock(); } - Err(MobileAuthenticationError::passphrase_required( + Err(failure.unwrap_or_else(|| MobileAuthenticationError::passphrase_required( "Biometric unlock could not restore this key. Enter its GPG passphrase to recover.", - )) + ))) } None => Err(MobileAuthenticationError::passphrase_required( "Enter the GPG key passphrase to continue.", @@ -533,6 +540,103 @@ impl MobileAuthentication { } } + /// Explicit enrollment also works before the store contains a password entry. + pub fn enroll_biometric_unlock( + &self, + passphrase: SecretBytes, + ) -> Result { + self.ensure_repository_idle()?; + self.keys + .validate_secret_passphrase(self.config.default_key().as_str(), Some(&passphrase)) + .map_err(|error| match error { + CryptoError::InvalidKeyPassphrase => MobileAuthenticationError::new( + MobileAuthenticationErrorKind::InvalidPassphrase, + "Incorrect Passphrase", + "The GPG key could not be unlocked.", + ), + error => key_error(error), + })?; + let fingerprint = self + .keys + .resolve(self.config.default_key().as_str()) + .map_err(key_error)?; + let key = self + .keys + .infos() + .find(|key| key.fingerprint() == fingerprint.fingerprint()) + .ok_or_else(|| key_error(CryptoError::MissingSecretKey))?; + let handle = self + .session + .authenticate_with_passphrase(&key, passphrase) + .map_err(MobileAuthenticationError::authentication)?; + if let Err(error) = self.persist_biometric_unlock(&handle, &key) { + let _ = self.manual_lock(); + return Err(error); + } + self.set_active(handle, key)?; + self.state() + } + + /// Restore the configured key's lease for an explicit protected-access request. + pub fn restore_biometric_lease( + &self, + ) -> Result { + self.ensure_repository_idle()?; + let state = self.state()?; + if state.unlocked() { + return Ok(state); + } + if !state.biometric_unlock_enabled() { + return Err(MobileAuthenticationError::passphrase_required( + "Enter the GPG key passphrase to continue.", + )); + } + let fingerprint = self + .keys + .resolve(self.config.default_key().as_str()) + .map_err(key_error)?; + let key = self + .keys + .infos() + .find(|key| key.fingerprint() == fingerprint.fingerprint()) + .ok_or_else(|| key_error(CryptoError::MissingSecretKey))?; + let mut handle = self + .session + .authenticate(&key) + .map_err(MobileAuthenticationError::authentication)?; + let passphrase = handle.secret_for(&key).map_err(|_| locked_error())?; + if self + .keys + .validate_secret_passphrase(key.fingerprint().as_str(), Some(&passphrase)) + .is_err() + { + let _ = self.session.delete_key_passphrase(&key); + let _ = self.manual_lock(); + return Err(MobileAuthenticationError::passphrase_required( + "The protected passphrase is unusable. Enter its GPG passphrase to recover.", + )); + } + self.set_active(handle, key)?; + self.state() + } + + fn persist_biometric_unlock( + &self, + handle: &MobileAuthenticationHandle, + key: &KeyInfo, + ) -> Result<(), MobileAuthenticationError> { + handle + .persist_passphrase(key) + .map_err(MobileAuthenticationError::authentication)?; + if let Err(error) = self.config.update_biometric_unlock(true) { + let _ = self.delete_all_key_passphrases(); + let _ = self.manual_lock(); + return Err(config_error(error)); + } + self.status()?.biometric_unlock_enabled = true; + Ok(()) + } + pub fn set_biometric_unlock( &self, enabled: bool, @@ -548,17 +652,10 @@ impl MobileAuthentication { "Unlock a password entry before enabling biometric unlock.", ) })?; - active - .handle - .persist_passphrase(&active.key) - .map_err(MobileAuthenticationError::authentication)?; + let handle = active.handle.clone(); + let key = active.key.clone(); drop(status); - if let Err(error) = self.config.update_biometric_unlock(true) { - let _ = self.delete_all_key_passphrases(); - let _ = self.manual_lock(); - return Err(config_error(error)); - } - self.status()?.biometric_unlock_enabled = true; + self.persist_biometric_unlock(&handle, &key)?; } else { self.delete_all_key_passphrases()?; self.config @@ -1961,6 +2058,40 @@ mod tests { watch_preference, }; + #[test] + fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() { + use crate::authentication::SystemAuthenticationClock; + use std::sync::Arc; + let (_root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture(); + let authentication = private + .authentication(backend, Arc::new(SystemAuthenticationClock::default())) + .unwrap(); + assert!( + crate::mobile_passwords::navigation::navigate_repository( + &authentication.repository, + "", + None, + None, + false + ) + .unwrap() + .rows + .is_empty() + ); + assert!( + authentication + .enroll_biometric_unlock(crate::repository::SecretBytes::new( + b"fixture-alice-passphrase".to_vec() + )) + .unwrap() + .biometric_unlock_enabled() + ); + authentication.manual_lock().unwrap(); + assert!(authentication.restore_biometric_lease().unwrap().unlocked()); + authentication.set_biometric_unlock(false).unwrap(); + assert!(authentication.restore_biometric_lease().is_err()); + } + #[test] fn repository_actions_protect_editors_and_serialize_mutations() { assert_eq!( diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs index e95e1e4..9343a7b 100644 --- a/crates/storage/src/secret_store.rs +++ b/crates/storage/src/secret_store.rs @@ -517,6 +517,11 @@ impl SecretStore { self.backend.lock() } + /// Interrupt a platform prompt without waiting for the retrieval's state lock. + pub(crate) fn cancel_pending(&self) -> Result<(), SecretStoreError> { + self.backend.lock() + } + pub fn create( &self, reference: &SecretReference, diff --git a/crates/storage/tests/authentication_leases.rs b/crates/storage/tests/authentication_leases.rs index 5ede452..02495d6 100644 --- a/crates/storage/tests/authentication_leases.rs +++ b/crates/storage/tests/authentication_leases.rs @@ -98,6 +98,7 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co struct BackendState { values: BTreeMap, fault: Option, + protected_fault: Option, retrieves: usize, locks: usize, unlocks: usize, @@ -112,6 +113,10 @@ impl MemoryBackend { self.0.lock().expect("test mutex").fault = Some(error); } + fn fail_protected(&self, error: SecretStoreError) { + self.0.lock().unwrap().protected_fault = Some(error); + } + fn retrieves(&self) -> usize { self.0.lock().expect("test mutex").retrieves } @@ -145,6 +150,11 @@ impl SecretStoreBackend for MemoryBackend { ) -> Result<(), SecretStoreError> { let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; Self::take_fault(&mut state)?; + if matches!(locator, SecretLocator::OpenPgpPassphrase { .. }) + && let Some(error) = state.protected_fault.take() + { + return Err(error); + } state.last_protection = Some(protection); if state.values.contains_key(locator) { return Err(SecretStoreError::AlreadyExists); @@ -179,6 +189,11 @@ impl SecretStoreBackend for MemoryBackend { ) -> Result<(), SecretStoreError> { let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; Self::take_fault(&mut state)?; + if matches!(locator, SecretLocator::OpenPgpPassphrase { .. }) + && let Some(error) = state.protected_fault.take() + { + return Err(error); + } state.last_protection = Some(protection); let existing = state .values @@ -529,3 +544,283 @@ fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease )); Ok(()) } + +#[test] +fn mobile_enrollment_proves_the_key_commits_only_after_protection_and_recovers() -> TestResult { + use ironstorage::{ + config::Config, + mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind}, + }; + let fixture = FixtureSet::load()?; + let vault = fixture.materialize_store("basic")?; + let root = tempfile::tempdir()?; + let source = root.path().join("config.toml"); + std::fs::write( + &source, + format!( + "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n", + vault.path().to_str().unwrap(), + fixture.path("keys").to_str().unwrap(), + fixture.key("alice")?.primary_fingerprint + ), + )?; + let backend = MemoryBackend::default(); + let clock = ManualClock::default(); + let make = || -> TestResult { + Ok(MobileAuthentication::with_platform( + Config::load(Some(&source))?, + Arc::new(backend.clone()), + Arc::new(clock.clone()), + )?) + }; + let authentication = make()?; + let password = + || SecretBytes::new(fixture.key("alice").unwrap().passphrase.as_bytes().to_vec()); + assert_eq!( + authentication + .enroll_biometric_unlock(SecretBytes::new(b"incorrect".to_vec())) + .unwrap_err() + .kind(), + MobileAuthenticationErrorKind::InvalidPassphrase + ); + assert!(backend.0.lock().unwrap().values.is_empty()); + for (fault, kind) in [ + ( + SecretStoreError::Cancelled, + MobileAuthenticationErrorKind::Cancelled, + ), + ( + SecretStoreError::UnsupportedProtection, + MobileAuthenticationErrorKind::BiometryUnavailable, + ), + ] { + backend.fail_protected(fault); + assert_eq!( + authentication + .enroll_biometric_unlock(password()) + .unwrap_err() + .kind(), + kind + ); + assert!(!authentication.state()?.unlocked()); + assert!(!Config::load(Some(&source))?.biometric_unlock_enabled()); + assert!(backend.0.lock().unwrap().values.is_empty()); + } + let state = authentication.enroll_biometric_unlock(password())?; + assert!(state.unlocked() && state.biometric_unlock_enabled()); + assert_eq!( + backend.last_protection(), + Some(SecretProtection::BiometryCurrentSet) + ); + authentication.manual_lock()?; + assert!( + authentication + .unlock_entry("email/personal", None)? + .unlocked() + ); + let reads = backend.retrieves(); + authentication.unlock_entry("email/personal", None)?; + assert_eq!( + reads, + backend.retrieves(), + "an active shared lease needs no second prompt" + ); + authentication.manual_lock()?; + backend.fail_protected(SecretStoreError::Cancelled); + assert_eq!( + authentication + .unlock_entry("email/personal", Some(password())) + .unwrap_err() + .kind(), + MobileAuthenticationErrorKind::Cancelled + ); + let cancelled = authentication.state()?; + assert!(!cancelled.unlocked()); + assert_eq!(cancelled.remaining_seconds(), 0); + for (fault, kind) in [ + ( + SecretStoreError::Lockout, + MobileAuthenticationErrorKind::BiometryUnavailable, + ), + ( + SecretStoreError::Denied, + MobileAuthenticationErrorKind::BiometryUnavailable, + ), + ( + SecretStoreError::Cancelled, + MobileAuthenticationErrorKind::Cancelled, + ), + ( + SecretStoreError::Invalidated, + MobileAuthenticationErrorKind::PassphraseRequired, + ), + ( + SecretStoreError::DeviceKeyMissing, + MobileAuthenticationErrorKind::PassphraseRequired, + ), + ] { + backend.fail_next(fault); + assert_eq!( + authentication + .unlock_entry("email/personal", None) + .unwrap_err() + .kind(), + kind + ); + assert!(!authentication.state()?.unlocked()); + } + backend.invalidate_enrollment(); + assert_eq!( + authentication + .unlock_entry("email/personal", None) + .unwrap_err() + .kind(), + MobileAuthenticationErrorKind::PassphraseRequired + ); + assert!( + authentication + .enroll_biometric_unlock(password())? + .unlocked() + ); + authentication.manual_lock()?; + drop(authentication); + let recreated = make()?; + assert!( + !recreated.state()?.unlocked(), + "process recreation cannot restore a lease" + ); + assert!(recreated.unlock_entry("email/personal", None)?.unlocked()); + recreated.set_biometric_unlock(false)?; + assert!(!recreated.state()?.unlocked()); + assert!(backend.0.lock().unwrap().values.is_empty()); + assert!(!Config::load(Some(&source))?.biometric_unlock_enabled()); + Ok(()) +} + +#[test] +fn mobile_expiry_and_user_input_never_reveal_old_editor_handles() -> TestResult { + use ironstorage::{config::Config, mobile_authentication::MobileAuthentication}; + let fixture = FixtureSet::load()?; + let vault = fixture.materialize_store("basic")?; + let root = tempfile::tempdir()?; + let source = root.path().join("config.toml"); + std::fs::write( + &source, + format!( + "vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n", + vault.path().to_str().unwrap(), + fixture.path("keys").to_str().unwrap(), + fixture.key("alice")?.primary_fingerprint + ), + )?; + let backend = MemoryBackend::default(); + let clock = ManualClock::default(); + let authentication = MobileAuthentication::with_platform( + Config::load(Some(&source))?, + Arc::new(backend), + Arc::new(clock.clone()), + )?; + authentication.set_authentication_timeout(2)?; + 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(1)); + assert_eq!(authentication.state()?.remaining_seconds(), 1); + authentication.touch_user_activity()?; + assert_eq!(authentication.state()?.remaining_seconds(), 2); + clock.advance(Duration::from_secs(2)); + assert!(!authentication.state()?.unlocked()); + assert!(authentication.entry_editor(editor.id()).is_err()); + assert!(authentication.entry_page("email/personal", 0).is_err()); + assert!(authentication.touch_user_activity().is_err()); + Ok(()) +} + +#[test] +fn locking_interrupts_an_os_prompt_before_waiting_for_the_lease_mutex() -> TestResult { + struct PromptBackend { + inner: MemoryBackend, + started: std::sync::mpsc::Sender<()>, + cancelled: (Mutex, std::sync::Condvar), + } + impl SecretStoreBackend for PromptBackend { + fn create( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + self.inner.create(locator, protection, value) + } + fn replace( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + self.inner.replace(locator, protection, value) + } + fn delete( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result<(), SecretStoreError> { + self.inner.delete(locator, protection) + } + fn retrieve( + &self, + _: &SecretLocator, + _: SecretProtection, + ) -> Result { + self.started.send(()).unwrap(); + let cancelled = self.cancelled.0.lock().unwrap(); + let (cancelled, wait) = self + .cancelled + .1 + .wait_timeout_while(cancelled, Duration::from_secs(3), |cancelled| !*cancelled) + .unwrap(); + assert!( + !wait.timed_out() && *cancelled, + "lock must interrupt the OS prompt" + ); + Err(SecretStoreError::Cancelled) + } + fn lock(&self) -> Result<(), SecretStoreError> { + *self.cancelled.0.lock().unwrap() = true; + self.cancelled.1.notify_all(); + self.inner.lock() + } + fn unlock(&self) -> Result<(), SecretStoreError> { + self.inner.unlock() + } + } + let fixture = FixtureSet::load()?; + let keys = KeyStore::load(fixture.path("keys"))?; + let alice = fixture_key(&fixture, &keys, "alice")?; + let (started, ready) = std::sync::mpsc::channel(); + let backend = Arc::new(PromptBackend { + inner: MemoryBackend::default(), + started, + cancelled: (Mutex::new(false), std::sync::Condvar::new()), + }); + let session = AuthenticationSession::with_clock( + backend, + SecretProtectionPolicy::current_biometry_for_openpgp(), + AuthenticationTimeout::default(), + ManualClock::default(), + ); + let worker_session = session.clone(); + let worker = std::thread::spawn(move || worker_session.authenticate(&alice)); + ready.recv_timeout(Duration::from_secs(2))?; + session.manual_lock()?; + assert!(matches!( + worker.join().unwrap(), + Err(AuthenticationError::Cancelled) + )); + assert_eq!(session.remaining_time()?, None); + Ok(()) +} diff --git a/docs/android/authentication.md b/docs/android/authentication.md new file mode 100644 index 0000000..d60387e --- /dev/null +++ b/docs/android/authentication.md @@ -0,0 +1,77 @@ +# Android biometric authentication (#98) + +Passwords and Preferences use one core facade over the private runtime's shared +Rust authentication owner. Rust validates the key, chooses protected operations, +owns the lease generation and inactivity deadline, and revokes native entry and +editor handles. Kotlin publishes that typed state and reports actual user +interaction; polling never extends the lease. Moving between tabs or browsing +folders retains a valid lease without requesting another biometric operation. +Backgrounding, manual Lock and Cancel immediately mask protected presentation +and interrupt a pending OS prompt before waiting for Rust to revoke its handles. +Obsolete requests cannot publish plaintext or overwrite newer authentication state. + +Preferences can explicitly enroll biometric unlock even in an empty store. A +secure Compose dialog collects the GPG passphrase without saved state, disables +keyboard correction, masks input and clears it on submission, dismissal, +backgrounding or disposal. Rust proves the configured secret key's passphrase, +then persists the passphrase through the existing current-biometric-set secure +store and commits the setting only after the OS-authenticated cipher succeeds. +Disabling deletes the protected passphrases and revokes the shared lease. + +AndroidX BiometricPrompt authenticates the Keystore cipher supplied by the +platform adapter with BIOMETRIC_STRONG. Current-set records never request device +credential fallback. The explicit Unlock action restores and validates the +configured key's protected passphrase; password-entry Unlock retains Rust's +recipient selection. Missing or unusable records offer the manual dialog. +Manual recovery while biometric unlock is enabled explicitly renews the protected +record; if strong biometrics are unavailable, disable biometric unlock in +Preferences before using manual password-entry unlock. Kotlin stores neither the +passphrase nor a duplicate authentication deadline in preferences, saved state, +files or WorkManager. + +Capability/no-enrollment, denied access, cancellation, temporary/permanent +lockout, missing or invalidated keys and storage failures retain typed Rust +outcomes. Lockout maps to BiometryUnavailable with manual recovery; cancellation +remains Cancelled. Process recreation starts locked and does not resurrect a +lease. Protected fields are rendered only while the shared lease is unlocked, +even if an older page result is still present. Ordinary folders and cached +non-secret TOTP discovery do not invoke an authentication prompt. + +## Validation + +Rust tests use a fake monotonic clock and injected secure-store outcomes to check +wrong passphrases, failed/cancelled enrollment without setting publication, +immediate lease revocation after cancelled manual biometric renewal, +current-set protection, one lease without repeated prompts, typed lockout and +invalidation, renewed enrollment, process recreation, timeout/activity touches, +editor-handle revocation and interruption of a blocked platform prompt. An empty +store regression proves enrollment does not require an existing password entry. + +Compose tests check explicit dialog submission, typed enrollment success, +manual recovery, lease masking, enable/disable/lock actions and clearing input +on cancellation, backgrounding and remount. The opt-in app instrumentation test +uses the production core facades, actual Rust and Android Keystore with isolated +Rust directories and namespaced OS aliases. It exercises authenticated cipher +enrollment/unlock, cancellation, background revocation, changed fingerprint +invalidation, removal of device protection, rejection of unsupported enrollment +and recovery after restoring a synthetic test PIN/fingerprint. Its arguments are +`-e class de.rfc1437.ironstorage.AuthenticationLeaseTest -e biometricLease true`. +The fixture stages are driven by emulator fingerprint controls/test automation; +only public disposable credentials are used. This test deliberately changes +emulator enrollment and lock-screen protection, so use a disposable test device +and restore its synthetic setup before subsequent tests. + +Validation scope is ARM64 API 26/4 KB, API 37/4 KB and strict API 37/16 KB. +ARMv7 is compiled and package-audited; no ARMv7 system image is available locally. +Physical devices are excluded by the milestone's explicit emulator-only scope. +No Apple project or frontend changes are required. + +Final verification passed all four required root Rust gates (477 workspace tests), +the complete Android debug/release/AAB, lint, JVM, style, architecture, ELF/ZIP +16 KB, manifest/backup, dependency/license and SBOM gates, and app plus native +instrumentation on all three configurations. Each configuration passed five +native cold loads and five application cold starts. The real biometric lifecycle +and capability/recovery test passed on API 37/4 KB with synthetic credentials. +Computer use verified the active OS prompt, production Preferences and secure +manual enrollment dialog in Android Studio's embedded emulator; approved CLI +and test automation drove fingerprint events and disposable fixture actions.