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

+2 -1
View File
@@ -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.
@@ -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(
@@ -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<AuthenticationState>
fun reload()
fun unlock()
fun enroll(passphrase: String)
fun disable()
fun lock()
fun cancel()
fun touch()
}
@@ -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<AuthenticationState> = 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 <T> 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
)
@@ -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<PasswordState> = 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 }
)
}
}
}
@@ -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
@@ -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))
}
}
)
}
@@ -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<String>()
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(
@@ -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 {
@@ -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() {
@@ -63,4 +63,20 @@
<string name="password_close">Close</string>
<string name="password_unlock">Unlock</string>
<string name="password_lock">Lock</string>
<string name="auth_title">Authentication</string>
<string name="auth_enabled">Biometric unlock enabled</string>
<string name="auth_disabled">Biometric unlock disabled</string>
<string name="auth_enable">Enable biometric unlock</string>
<string name="auth_disable">Disable biometric unlock</string>
<string name="auth_manual">Use GPG passphrase</string>
<string name="auth_manual_detail">The passphrase is validated by Rust and retained only for the authentication lease.</string>
<string name="auth_recover_detail">After validating the passphrase, renew the enabled biometric record using a matching biometric.</string>
<string name="auth_enroll_title">Enroll biometric unlock</string>
<string name="auth_enroll_detail">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.</string>
<string name="auth_continue">Continue</string>
<string name="auth_cancel">Cancel</string>
<plurals name="auth_lease">
<item quantity="one">Unlocked: %1$d second remaining</item>
<item quantity="other">Unlocked: %1$d seconds remaining</item>
</plurals>
</resources>