Integrate Android biometrics with the shared Rust lease (#98)
Android ARM verification / rust (push) Failing after 47m23s
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 16:32:01 +02:00
1 parent 5c933a99a3
commit 647dc1bb4b
21 files changed
+1473 -118

No files matched your search

@@ -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<CountDownLatch?>()
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<String> = os.aliases().filter {
it.startsWith(prefix)
}.map { it.removePrefix(prefix) }
}
}
@@ -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<MainActivity>()
@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
}
}
@@ -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(
@@ -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()
@@ -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(