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 # Native Android build
Architecture and issue ownership: [ADR](../docs/android/architecture.md) and 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 callbacks and build gates. Both variants invoke Rust through the generated
UniFFI API. Local-first and HTTPS onboarding is described in UniFFI API. Local-first and HTTPS onboarding is described in
[onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned, [onboarding](../docs/android/onboarding.md). The Passwords tab uses Rust-owned,
revision-checked [adaptive navigation](../docs/android/password-navigation.md); revision-checked [adaptive navigation](../docs/android/password-navigation.md);
Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.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 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. 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.performSemanticsAction
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.unit.Density 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.PasswordField
import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRepository
@@ -54,7 +58,7 @@ class PasswordScreenTest {
compose.activity.runOnUiThread { compose.activity.runOnUiThread {
compose.activity.setContent { compose.activity.setContent {
inputMode = LocalInputModeManager.current inputMode = LocalInputModeManager.current
MaterialTheme { PasswordScreen(repository) } MaterialTheme { PasswordScreen(repository, repository.authentication) }
} }
} }
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) } compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
@@ -84,12 +88,19 @@ class PasswordScreenTest {
compose.onAllNodesWithText("Actions")[0].performClick() compose.onAllNodesWithText("Actions")[0].performClick()
compose.onNodeWithText("Open").performClick() compose.onNodeWithText("Open").performClick()
compose.onNodeWithText("Unlock").assertIsDisplayed() compose.onNodeWithText("Unlock").assertIsDisplayed()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithText("GPG passphrase").performTextInput("incorrect") compose.onNodeWithText("GPG passphrase").performTextInput("incorrect")
compose.onNodeWithText("Unlock").performClick() compose.onNodeWithText("Continue").performClick()
compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed() compose.onNodeWithText("Incorrect passphrase. Retry unlocking.").assertIsDisplayed()
compose.onNodeWithText("Use GPG passphrase").performClick()
compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase") compose.onNodeWithText("GPG passphrase").performTextInput("public-test-passphrase")
compose.onNodeWithText("Unlock").performClick() compose.onNodeWithText("Continue").performClick()
compose.onNodeWithText("Fixture account").assertIsDisplayed() 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("Lock").performClick()
compose.onNodeWithText("Unlock").assertIsDisplayed() compose.onNodeWithText("Unlock").assertIsDisplayed()
repository.replaceOnRefresh = true repository.replaceOnRefresh = true
@@ -117,7 +128,7 @@ class PasswordScreenTest {
compose.activity.setContent { compose.activity.setContent {
val density = LocalDensity.current val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) { CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
MaterialTheme { PasswordScreen(repository) } MaterialTheme { PasswordScreen(repository, repository.authentication) }
} }
} }
} }
@@ -151,6 +162,19 @@ class PasswordScreenTest {
) )
override val state = override val state =
MutableStateFlow(PasswordState(revision = "root", rows = listOf(folder))) 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 lastId: String? = null
var replaceOnRefresh = false var replaceOnRefresh = false
override fun restore(path: String, selected: String?, revision: String?) = Unit override fun restore(path: String, selected: String?, revision: String?) = Unit
@@ -190,10 +214,21 @@ class PasswordScreenTest {
} }
} }
override fun unlock(passphrase: String) { 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") { if (passphrase != "public-test-passphrase") {
state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.") state.value = state.value.copy(error = "Incorrect passphrase. Retry unlocking.")
return return
} }
lease.value = AuthenticationState(unlocked = true)
assertEquals("public-test-passphrase", passphrase) assertEquals("public-test-passphrase", passphrase)
state.value = state.value =
state.value.copy( state.value.copy(
@@ -7,6 +7,8 @@ import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.AndroidEntryPoint 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.core.ShellRepository
import de.rfc1437.ironstorage.features.SetupFormViewModel import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel import de.rfc1437.ironstorage.features.ShellViewModel
@@ -19,10 +21,24 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var repository: ShellRepository @Inject lateinit var repository: ShellRepository
@Inject lateinit var authentication: AuthenticationRepository
@Inject lateinit var passwords: PasswordRepository
private val setupForm: SetupFormViewModel by viewModels() private val setupForm: SetupFormViewModel by viewModels()
private val viewModel: ShellViewModel 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() { override fun onDestroy() {
keystore.detach(this) keystore.detach(this)
super.onDestroy() super.onDestroy()
@@ -6,7 +6,9 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
import de.rfc1437.ironstorage.core.NativeFixtureShellRepository import de.rfc1437.ironstorage.core.NativeFixtureShellRepository
import de.rfc1437.ironstorage.core.NativeHomeRepository import de.rfc1437.ironstorage.core.NativeHomeRepository
import de.rfc1437.ironstorage.core.NativeOnboardingRepository import de.rfc1437.ironstorage.core.NativeOnboardingRepository
@@ -39,12 +41,29 @@ object ShellModule {
@Provides @Provides
@Singleton @Singleton
fun passwordRepository(session: NativePrivateSession): PasswordRepository = fun passwordRepository(
NativePasswordRepository( session: NativePrivateSession,
authentication: NativeAuthenticationRepository
): PasswordRepository = NativePasswordRepository(
session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
authentication,
Dispatchers.IO
)
@Provides
@Singleton
fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository =
NativeAuthenticationRepository(
session, session,
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
Dispatchers.IO
) )
@Provides
fun authentication(repository: NativeAuthenticationRepository): AuthenticationRepository =
repository
@Provides @Provides
@Singleton @Singleton
fun privateSession( 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 package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException 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.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -22,35 +18,18 @@ import kotlinx.coroutines.withContext
class NativePasswordRepository( class NativePasswordRepository(
private val session: NativePrivateSession, private val session: NativePrivateSession,
private val scope: CoroutineScope private val scope: CoroutineScope,
private val authentication: NativeAuthenticationRepository,
private val io: CoroutineDispatcher
) : PasswordRepository { ) : PasswordRepository {
private val mutable = MutableStateFlow(PasswordState()) private val mutable = MutableStateFlow(PasswordState())
override val state: StateFlow<PasswordState> = mutable override val state: StateFlow<PasswordState> = mutable
private val sequence = AtomicLong() private val sequence = AtomicLong()
private val mutex = Mutex() private val mutex = Mutex()
private var authentication: MobileAuthentication? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
init { init {
scope.launch { scope.launch {
while (true) { authentication.state.collect { lease ->
delay(1000) if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null)
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)
}
}
} }
} }
} }
@@ -81,8 +60,6 @@ class NativePasswordRepository(
} }
private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) = private fun navigate(path: String, selected: String?, revision: String?, activate: Boolean) =
run { run {
authentication?.manualLock()
authentication = null
session.storage().passwordNavigation(path, selected, revision, activate).toFacade() session.storage().passwordNavigation(path, selected, revision, activate).toFacade()
} }
override fun unlock(passphrase: String) { override fun unlock(passphrase: String) {
@@ -97,19 +74,15 @@ class NativePasswordRepository(
false false
).toFacade() ).toFacade()
if (verified.selected == null) return@run verified if (verified.selected == null) return@run verified
val auth = val page = authentication.authenticated { auth ->
authentication auth.navigationEntry(
?: session.storage().authentication(session.backend(), clock).also { current.path,
authentication = selected.id,
it current.revision,
} passphrase.ifEmpty { null },
val page = auth.navigationEntry( (System.currentTimeMillis() / 1000).toULong()
current.path, )
selected.id, }
current.revision,
passphrase.ifEmpty { null },
(System.currentTimeMillis() / 1000).toULong()
)
verified.copy( verified.copy(
sections = page.page.sections.map { section -> sections = page.page.sections.map { section ->
PasswordSection( PasswordSection(
@@ -130,26 +103,16 @@ class NativePasswordRepository(
override fun lock() { override fun lock() {
sequence.incrementAndGet() sequence.incrementAndGet()
mutable.value = mutable.value.copy(sections = null, loading = false) mutable.value = mutable.value.copy(sections = null, loading = false)
scope.launch { authentication.lock()
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."
)
}
}
}
} }
private fun run(action: () -> PasswordState) { private fun run(action: suspend () -> PasswordState) {
val ticket = sequence.incrementAndGet() val ticket = sequence.incrementAndGet()
mutable.value = mutable.value.copy(loading = true, error = null, sections = null) mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
scope.launch { scope.launch {
mutex.withLock { mutex.withLock {
if (sequence.get() != ticket) return@withLock if (sequence.get() != ticket) return@withLock
val result = try { val result = try {
withContext(Dispatchers.IO) { action() } withContext(io) { action() }
} catch (error: MobilePasswordFfiException.Failed) { } catch (error: MobilePasswordFfiException.Failed) {
mutable.value.copy(error = error.detail, selected = null) mutable.value.copy(error = error.detail, selected = null)
} catch (error: MobileAuthenticationFfiException.Failed) { } catch (error: MobileAuthenticationFfiException.Failed) {
@@ -167,9 +130,10 @@ class NativePasswordRepository(
) )
} }
if (sequence.get() == ticket) { if (sequence.get() == ticket) {
mutable.value = result.copy(loading = false) mutable.value = result.copy(
} else { loading = false,
withContext(Dispatchers.IO) { authentication?.manualLock() } sections = result.sections.takeIf { authentication.state.value.unlocked }
)
} }
} }
} }
@@ -13,6 +13,12 @@ class NativePrivateSession(
private val keystore: AndroidKeystore private val keystore: AndroidKeystore
) { ) {
val runtime = NativeRuntime(context) val runtime = NativeRuntime(context)
fun cancelAuthentication() = keystore.cancelPending()
fun initialize() {
storage()
}
private var initialized: MobilePrivateRuntime? = null private var initialized: MobilePrivateRuntime? = null
private var secrets: MobileSecretBackend? = 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.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi 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.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.PasswordKind
import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellIcon import de.rfc1437.ironstorage.core.ShellIcon
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) { fun PasswordScreen(
repository: PasswordRepository,
authentication: AuthenticationRepository,
modifier: Modifier = Modifier
) {
val state by repository.state.collectAsStateWithLifecycle() val state by repository.state.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>() val navigator = rememberListDetailPaneScaffoldNavigator<String>()
var actions by remember { mutableStateOf(false) } 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 } val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry }
LaunchedEffect(entry?.id) { LaunchedEffect(entry?.id) {
passphrase = "" manual = false
requested = false
navigator.navigateTo( navigator.navigateTo(
if (entry == if (entry ==
null null
@@ -72,13 +78,13 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
) )
} }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
passphrase = "" manual = false
requested = false
repository.lock() repository.lock()
} }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (state.revision.isNotEmpty()) repository.refresh() if (state.revision.isNotEmpty()) repository.refresh()
} }
DisposableEffect(repository) { onDispose { repository.lock() } }
BackHandler(state.selected != null || state.parent != null) { BackHandler(state.selected != null || state.parent != null) {
actions = false actions = false
repository.back() repository.back()
@@ -217,28 +223,22 @@ fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier
Text(it, color = MaterialTheme.colorScheme.error) Text(it, color = MaterialTheme.colorScheme.error)
} }
} }
val sections = state.sections val sections = state.sections.takeIf { lease.unlocked }
if (sections == null) { if (sections == null) {
item { item {
Text(entry.detail) Text(entry.detail)
OutlinedTextField( Button(enabled = !state.loading && !lease.busy, onClick = {
passphrase, requested = true
{ repository.unlock("")
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)
}) { Text(stringResource(R.string.password_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 { } else {
item { 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) { if (actions && state.selected != null && !state.loading) {
val selected = requireNotNull(state.selected) val selected = requireNotNull(state.selected)
AlertDialog( AlertDialog(
@@ -91,10 +91,13 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
snapshot.storageRecovery == null && snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences) (snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
) { ) {
if (ready && snapshot.selectedTab == ShellTab.Preferences) {
AuthenticationControls(viewModel.authentication)
}
OnboardingScreen(viewModel.onboarding, setupForm) OnboardingScreen(viewModel.onboarding, setupForm)
} else if (browsing) { } else if (browsing) {
LaunchedEffect(viewModel) { viewModel.openPasswords() } LaunchedEffect(viewModel) { viewModel.openPasswords() }
PasswordScreen(viewModel.passwords, Modifier.weight(1f)) PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f))
} else if (home) { } else if (home) {
HomeScreen(viewModel.home, Modifier.weight(1f)) HomeScreen(viewModel.home, Modifier.weight(1f))
} else { } else {
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.HomeRepository import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRepository
@@ -18,6 +19,7 @@ class ShellViewModel @Inject constructor(
val onboarding: OnboardingRepository, val onboarding: OnboardingRepository,
val passwords: PasswordRepository, val passwords: PasswordRepository,
val home: HomeRepository, val home: HomeRepository,
val authentication: AuthenticationRepository,
private val saved: SavedStateHandle private val saved: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
val snapshot = repository.snapshot val snapshot = repository.snapshot
@@ -47,11 +49,6 @@ class ShellViewModel @Inject constructor(
} }
} }
fun selectTab(tab: ShellTab) { fun selectTab(tab: ShellTab) {
if (tab !=
ShellTab.Passwords
) {
passwords.lock()
}
repository.selectTab(tab) repository.selectTab(tab)
} }
override fun onCleared() { override fun onCleared() {
@@ -63,4 +63,20 @@
<string name="password_close">Close</string> <string name="password_close">Close</string>
<string name="password_unlock">Unlock</string> <string name="password_unlock">Unlock</string>
<string name="password_lock">Lock</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> </resources>
+21
View File
@@ -1762,6 +1762,27 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn enroll_biometric_unlock(
&self,
passphrase: String,
) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
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<MobileAuthenticationState, MobileAuthenticationFfiError> {
self.authentication
.restore_biometric_lease()
.map(Into::into)
.map_err(Into::into)
}
pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationFfiError> { pub fn touch_user_activity(&self) -> Result<(), MobileAuthenticationFfiError> {
self.authentication self.authentication
.touch_user_activity() .touch_user_activity()
+6 -2
View File
@@ -310,14 +310,18 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
/// Revoke every outstanding handle immediately. /// Revoke every outstanding handle immediately.
pub fn manual_lock(&self) -> Result<(), AuthenticationError> { pub fn manual_lock(&self) -> Result<(), AuthenticationError> {
let interrupted = self.shared.store.cancel_pending();
let _operation = self.shared.operation()?; 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. /// Cancel authentication and revoke any lease created by the pending interaction.
pub fn cancel(&self) -> Result<(), AuthenticationError> { pub fn cancel(&self) -> Result<(), AuthenticationError> {
let interrupted = self.shared.store.cancel_pending();
let _operation = self.shared.operation()?; 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. /// Apply a new shared inactivity timeout to this process and any active lease.
+149 -18
View File
@@ -481,10 +481,10 @@ impl MobileAuthentication {
match self.keys.decrypt(ciphertext, &mut provider) { match self.keys.decrypt(ciphertext, &mut provider) {
Ok(plaintext) => { Ok(plaintext) => {
drop(plaintext); drop(plaintext);
if biometric_enabled { if biometric_enabled && let Err(error) = handle.persist_passphrase(key)
handle {
.persist_passphrase(key) let _ = self.manual_lock();
.map_err(MobileAuthenticationError::authentication)?; return Err(MobileAuthenticationError::authentication(error));
} }
self.set_active(handle, key.clone())?; self.set_active(handle, key.clone())?;
return self.state(); return self.state();
@@ -494,7 +494,10 @@ impl MobileAuthentication {
.manual_lock() .manual_lock()
.map_err(MobileAuthenticationError::authentication)?; .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( Err(MobileAuthenticationError::new(
@@ -504,6 +507,7 @@ impl MobileAuthentication {
)) ))
} }
None if biometric_enabled => { None if biometric_enabled => {
let mut failure = None;
for key in &candidates { for key in &candidates {
let handle = match self.session.authenticate(key) { let handle = match self.session.authenticate(key) {
Ok(handle) => handle, Ok(handle) => handle,
@@ -512,7 +516,10 @@ impl MobileAuthentication {
AuthenticationError::Cancelled, AuthenticationError::Cancelled,
)); ));
} }
Err(_) => continue, Err(error) => {
failure = Some(MobileAuthenticationError::authentication(error));
continue;
}
}; };
let mut provider = KeyOnlyProvider::new(handle.clone(), key); let mut provider = KeyOnlyProvider::new(handle.clone(), key);
if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) { 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.delete_key_passphrase(key);
let _ = self.session.manual_lock(); 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.", "Biometric unlock could not restore this key. Enter its GPG passphrase to recover.",
)) )))
} }
None => Err(MobileAuthenticationError::passphrase_required( None => Err(MobileAuthenticationError::passphrase_required(
"Enter the GPG key passphrase to continue.", "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<MobileAuthenticationState, MobileAuthenticationError> {
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<MobileAuthenticationState, MobileAuthenticationError> {
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( pub fn set_biometric_unlock(
&self, &self,
enabled: bool, enabled: bool,
@@ -548,17 +652,10 @@ impl MobileAuthentication {
"Unlock a password entry before enabling biometric unlock.", "Unlock a password entry before enabling biometric unlock.",
) )
})?; })?;
active let handle = active.handle.clone();
.handle let key = active.key.clone();
.persist_passphrase(&active.key)
.map_err(MobileAuthenticationError::authentication)?;
drop(status); drop(status);
if let Err(error) = self.config.update_biometric_unlock(true) { self.persist_biometric_unlock(&handle, &key)?;
let _ = self.delete_all_key_passphrases();
let _ = self.manual_lock();
return Err(config_error(error));
}
self.status()?.biometric_unlock_enabled = true;
} else { } else {
self.delete_all_key_passphrases()?; self.delete_all_key_passphrases()?;
self.config self.config
@@ -1961,6 +2058,40 @@ mod tests {
watch_preference, 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] #[test]
fn repository_actions_protect_editors_and_serialize_mutations() { fn repository_actions_protect_editors_and_serialize_mutations() {
assert_eq!( assert_eq!(
+5
View File
@@ -517,6 +517,11 @@ impl<B: SecretStoreBackend> SecretStore<B> {
self.backend.lock() 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( pub fn create(
&self, &self,
reference: &SecretReference, reference: &SecretReference,
@@ -98,6 +98,7 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
struct BackendState { struct BackendState {
values: BTreeMap<SecretLocator, SecretBytes>, values: BTreeMap<SecretLocator, SecretBytes>,
fault: Option<SecretStoreError>, fault: Option<SecretStoreError>,
protected_fault: Option<SecretStoreError>,
retrieves: usize, retrieves: usize,
locks: usize, locks: usize,
unlocks: usize, unlocks: usize,
@@ -112,6 +113,10 @@ impl MemoryBackend {
self.0.lock().expect("test mutex").fault = Some(error); 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 { fn retrieves(&self) -> usize {
self.0.lock().expect("test mutex").retrieves self.0.lock().expect("test mutex").retrieves
} }
@@ -145,6 +150,11 @@ impl SecretStoreBackend for MemoryBackend {
) -> Result<(), SecretStoreError> { ) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?; 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); state.last_protection = Some(protection);
if state.values.contains_key(locator) { if state.values.contains_key(locator) {
return Err(SecretStoreError::AlreadyExists); return Err(SecretStoreError::AlreadyExists);
@@ -179,6 +189,11 @@ impl SecretStoreBackend for MemoryBackend {
) -> Result<(), SecretStoreError> { ) -> Result<(), SecretStoreError> {
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
Self::take_fault(&mut state)?; 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); state.last_protection = Some(protection);
let existing = state let existing = state
.values .values
@@ -529,3 +544,283 @@ fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease
)); ));
Ok(()) 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<MobileAuthentication> {
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<bool>, 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<SecretBytes, SecretStoreError> {
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(())
}
+77
View File
@@ -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.