Integrate Android biometrics with the shared Rust lease (#98)
This commit is contained in:
1 parent
5c933a99a3
commit
647dc1bb4b
21 files changed
+1473
-118
No files matched your search
+2
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
+112
@@ -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()
|
||||
}
|
||||
+206
@@ -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
|
||||
)
|
||||
+22
-58
@@ -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
|
||||
|
||||
|
||||
+147
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+57
-26
@@ -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>
|
||||
@@ -1762,6 +1762,27 @@ impl MobileAuthentication {
|
||||
.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> {
|
||||
self.authentication
|
||||
.touch_user_activity()
|
||||
|
||||
@@ -310,14 +310,18 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
||||
|
||||
/// Revoke every outstanding handle immediately.
|
||||
pub fn manual_lock(&self) -> Result<(), AuthenticationError> {
|
||||
let interrupted = self.shared.store.cancel_pending();
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.revoke(RevocationReason::Manual)
|
||||
self.shared.revoke(RevocationReason::Manual)?;
|
||||
interrupted.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Cancel authentication and revoke any lease created by the pending interaction.
|
||||
pub fn cancel(&self) -> Result<(), AuthenticationError> {
|
||||
let interrupted = self.shared.store.cancel_pending();
|
||||
let _operation = self.shared.operation()?;
|
||||
self.shared.revoke(RevocationReason::Cancelled)
|
||||
self.shared.revoke(RevocationReason::Cancelled)?;
|
||||
interrupted.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Apply a new shared inactivity timeout to this process and any active lease.
|
||||
|
||||
@@ -481,10 +481,10 @@ impl MobileAuthentication {
|
||||
match self.keys.decrypt(ciphertext, &mut provider) {
|
||||
Ok(plaintext) => {
|
||||
drop(plaintext);
|
||||
if biometric_enabled {
|
||||
handle
|
||||
.persist_passphrase(key)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
if biometric_enabled && let Err(error) = handle.persist_passphrase(key)
|
||||
{
|
||||
let _ = self.manual_lock();
|
||||
return Err(MobileAuthenticationError::authentication(error));
|
||||
}
|
||||
self.set_active(handle, key.clone())?;
|
||||
return self.state();
|
||||
@@ -494,7 +494,10 @@ impl MobileAuthentication {
|
||||
.manual_lock()
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
}
|
||||
Err(error) => return Err(key_error(error)),
|
||||
Err(error) => {
|
||||
let _ = self.manual_lock();
|
||||
return Err(key_error(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(MobileAuthenticationError::new(
|
||||
@@ -504,6 +507,7 @@ impl MobileAuthentication {
|
||||
))
|
||||
}
|
||||
None if biometric_enabled => {
|
||||
let mut failure = None;
|
||||
for key in &candidates {
|
||||
let handle = match self.session.authenticate(key) {
|
||||
Ok(handle) => handle,
|
||||
@@ -512,7 +516,10 @@ impl MobileAuthentication {
|
||||
AuthenticationError::Cancelled,
|
||||
));
|
||||
}
|
||||
Err(_) => continue,
|
||||
Err(error) => {
|
||||
failure = Some(MobileAuthenticationError::authentication(error));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut provider = KeyOnlyProvider::new(handle.clone(), key);
|
||||
if let Ok(plaintext) = self.keys.decrypt(ciphertext, &mut provider) {
|
||||
@@ -523,9 +530,9 @@ impl MobileAuthentication {
|
||||
let _ = self.session.delete_key_passphrase(key);
|
||||
let _ = self.session.manual_lock();
|
||||
}
|
||||
Err(MobileAuthenticationError::passphrase_required(
|
||||
Err(failure.unwrap_or_else(|| MobileAuthenticationError::passphrase_required(
|
||||
"Biometric unlock could not restore this key. Enter its GPG passphrase to recover.",
|
||||
))
|
||||
)))
|
||||
}
|
||||
None => Err(MobileAuthenticationError::passphrase_required(
|
||||
"Enter the GPG key passphrase to continue.",
|
||||
@@ -533,6 +540,103 @@ impl MobileAuthentication {
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit enrollment also works before the store contains a password entry.
|
||||
pub fn enroll_biometric_unlock(
|
||||
&self,
|
||||
passphrase: SecretBytes,
|
||||
) -> Result<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(
|
||||
&self,
|
||||
enabled: bool,
|
||||
@@ -548,17 +652,10 @@ impl MobileAuthentication {
|
||||
"Unlock a password entry before enabling biometric unlock.",
|
||||
)
|
||||
})?;
|
||||
active
|
||||
.handle
|
||||
.persist_passphrase(&active.key)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
let handle = active.handle.clone();
|
||||
let key = active.key.clone();
|
||||
drop(status);
|
||||
if let Err(error) = self.config.update_biometric_unlock(true) {
|
||||
let _ = self.delete_all_key_passphrases();
|
||||
let _ = self.manual_lock();
|
||||
return Err(config_error(error));
|
||||
}
|
||||
self.status()?.biometric_unlock_enabled = true;
|
||||
self.persist_biometric_unlock(&handle, &key)?;
|
||||
} else {
|
||||
self.delete_all_key_passphrases()?;
|
||||
self.config
|
||||
@@ -1961,6 +2058,40 @@ mod tests {
|
||||
watch_preference,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() {
|
||||
use crate::authentication::SystemAuthenticationClock;
|
||||
use std::sync::Arc;
|
||||
let (_root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture();
|
||||
let authentication = private
|
||||
.authentication(backend, Arc::new(SystemAuthenticationClock::default()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
crate::mobile_passwords::navigation::navigate_repository(
|
||||
&authentication.repository,
|
||||
"",
|
||||
None,
|
||||
None,
|
||||
false
|
||||
)
|
||||
.unwrap()
|
||||
.rows
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
authentication
|
||||
.enroll_biometric_unlock(crate::repository::SecretBytes::new(
|
||||
b"fixture-alice-passphrase".to_vec()
|
||||
))
|
||||
.unwrap()
|
||||
.biometric_unlock_enabled()
|
||||
);
|
||||
authentication.manual_lock().unwrap();
|
||||
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||
authentication.set_biometric_unlock(false).unwrap();
|
||||
assert!(authentication.restore_biometric_lease().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_actions_protect_editors_and_serialize_mutations() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -517,6 +517,11 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
||||
self.backend.lock()
|
||||
}
|
||||
|
||||
/// Interrupt a platform prompt without waiting for the retrieval's state lock.
|
||||
pub(crate) fn cancel_pending(&self) -> Result<(), SecretStoreError> {
|
||||
self.backend.lock()
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
&self,
|
||||
reference: &SecretReference,
|
||||
|
||||
@@ -98,6 +98,7 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
||||
struct BackendState {
|
||||
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||
fault: Option<SecretStoreError>,
|
||||
protected_fault: Option<SecretStoreError>,
|
||||
retrieves: usize,
|
||||
locks: usize,
|
||||
unlocks: usize,
|
||||
@@ -112,6 +113,10 @@ impl MemoryBackend {
|
||||
self.0.lock().expect("test mutex").fault = Some(error);
|
||||
}
|
||||
|
||||
fn fail_protected(&self, error: SecretStoreError) {
|
||||
self.0.lock().unwrap().protected_fault = Some(error);
|
||||
}
|
||||
|
||||
fn retrieves(&self) -> usize {
|
||||
self.0.lock().expect("test mutex").retrieves
|
||||
}
|
||||
@@ -145,6 +150,11 @@ impl SecretStoreBackend for MemoryBackend {
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
if matches!(locator, SecretLocator::OpenPgpPassphrase { .. })
|
||||
&& let Some(error) = state.protected_fault.take()
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
state.last_protection = Some(protection);
|
||||
if state.values.contains_key(locator) {
|
||||
return Err(SecretStoreError::AlreadyExists);
|
||||
@@ -179,6 +189,11 @@ impl SecretStoreBackend for MemoryBackend {
|
||||
) -> Result<(), SecretStoreError> {
|
||||
let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
||||
Self::take_fault(&mut state)?;
|
||||
if matches!(locator, SecretLocator::OpenPgpPassphrase { .. })
|
||||
&& let Some(error) = state.protected_fault.take()
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
state.last_protection = Some(protection);
|
||||
let existing = state
|
||||
.values
|
||||
@@ -529,3 +544,283 @@ fn verified_manual_recovery_enrolls_current_biometry_and_reestablishes_the_lease
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_enrollment_proves_the_key_commits_only_after_protection_and_recovers() -> TestResult {
|
||||
use ironstorage::{
|
||||
config::Config,
|
||||
mobile_authentication::{MobileAuthentication, MobileAuthenticationErrorKind},
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let vault = fixture.materialize_store("basic")?;
|
||||
let root = tempfile::tempdir()?;
|
||||
let source = root.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&source,
|
||||
format!(
|
||||
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n",
|
||||
vault.path().to_str().unwrap(),
|
||||
fixture.path("keys").to_str().unwrap(),
|
||||
fixture.key("alice")?.primary_fingerprint
|
||||
),
|
||||
)?;
|
||||
let backend = MemoryBackend::default();
|
||||
let clock = ManualClock::default();
|
||||
let make = || -> TestResult<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(())
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user